mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-15 00:49:53 +00:00
Compare commits
22 Commits
fix/patch-
...
feat/openc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af16f1cc0c | ||
|
|
5da13e54b5 | ||
|
|
5ea8cff1b6 | ||
|
|
b494bbd41c | ||
|
|
f313aa532d | ||
|
|
a23fd55934 | ||
|
|
4e405681a7 | ||
|
|
b445615d61 | ||
|
|
d68e8905fe | ||
|
|
e89fccd997 | ||
|
|
805ae8e607 | ||
|
|
833baec84d | ||
|
|
7a2a8e09bc | ||
|
|
6f8da5b7fb | ||
|
|
50cbe48558 | ||
|
|
d81b99c8e3 | ||
|
|
86cb03a1fc | ||
|
|
7765d99c73 | ||
|
|
db5e55a174 | ||
|
|
fbae45eb97 | ||
|
|
554fcd7c06 | ||
|
|
eed158eca0 |
@@ -1,5 +1,5 @@
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef } from 'react'
|
||||
import { ArrowLeft, PanelRight } from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type {
|
||||
@@ -16,8 +16,14 @@ import {
|
||||
useUpdateHarnessAgent,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { type ProducedFilesRailGroup, useAgentOutputs } from '@/lib/agent-files'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AgentRail } from './AgentRail'
|
||||
import { useAgentCommandData } from './agent-command-layout'
|
||||
import {
|
||||
OutputsRail,
|
||||
useOutputsRailOpen,
|
||||
} from './agent-conversation.outputs-rail'
|
||||
import { ClawChat } from './ClawChat'
|
||||
import { ConversationHeader } from './ConversationHeader'
|
||||
import { ConversationInput } from './ConversationInput'
|
||||
@@ -25,6 +31,8 @@ import {
|
||||
buildChatHistoryFromClawMessages,
|
||||
filterTurnsPersistedInHistory,
|
||||
flattenHistoryPages,
|
||||
mapHistoryToProducedFilesGroups,
|
||||
selectStripOnlyTurns,
|
||||
} from './claw-chat-types'
|
||||
import { consumePendingInitialMessage } from './pending-initial-message'
|
||||
import { QueuePanel } from './QueuePanel'
|
||||
@@ -38,6 +46,7 @@ function AgentConversationController({
|
||||
agents,
|
||||
agentPathPrefix,
|
||||
createAgentPath,
|
||||
onOpenOutputsRail,
|
||||
}: {
|
||||
agentId: string
|
||||
initialMessage: string | null
|
||||
@@ -45,6 +54,7 @@ function AgentConversationController({
|
||||
agents: AgentEntry[]
|
||||
agentPathPrefix: string
|
||||
createAgentPath: string
|
||||
onOpenOutputsRail?: ((turnId?: string | null) => void) | null
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const initialMessageSentRef = useRef<string | null>(null)
|
||||
@@ -76,6 +86,15 @@ function AgentConversationController({
|
||||
const harnessAgent = harnessAgents.find((entry) => entry.id === agentId)
|
||||
const queue = harnessAgent?.queue ?? []
|
||||
const activeTurnId = harnessAgent?.activeTurnId ?? null
|
||||
const isOpenClawAgent = harnessAgent?.adapter === 'openclaw'
|
||||
|
||||
// Used to surface produced-files strips on a fresh page load
|
||||
// when there's no optimistic turn to carry the data. Disabled
|
||||
// for non-openclaw adapters since they don't attribute files.
|
||||
const { groups: agentOutputGroups } = useAgentOutputs(
|
||||
agentId,
|
||||
isOpenClawAgent,
|
||||
)
|
||||
|
||||
const { turns, streaming, send } = useAgentConversation(agentId, {
|
||||
runtime: 'agent-harness',
|
||||
@@ -100,6 +119,44 @@ function AgentConversationController({
|
||||
() => filterTurnsPersistedInHistory(turns, historyMessages),
|
||||
[historyMessages, turns],
|
||||
)
|
||||
// Persisted turns that still need to surface their FileCardStrip
|
||||
// — history items don't carry produced-files data, so without
|
||||
// these the strip would vanish on history reload.
|
||||
const stripOnlyTurns = useMemo(
|
||||
() => selectStripOnlyTurns(turns, historyMessages),
|
||||
[historyMessages, turns],
|
||||
)
|
||||
// Two outputs from the per-turn matcher:
|
||||
// - filesByAssistantId → strip rendered directly under the
|
||||
// matching assistant history bubble.
|
||||
// - tailUnmatched → groups with no history pair (orphans);
|
||||
// rendered at the conversation tail.
|
||||
// Both are filtered to exclude turnIds already covered by a
|
||||
// live or strip-only optimistic turn (those carry their own
|
||||
// strip and history hasn't reloaded yet).
|
||||
const { filesByAssistantId, tailStripGroups } = useMemo(() => {
|
||||
if (!isOpenClawAgent) {
|
||||
return {
|
||||
filesByAssistantId: new Map<string, ProducedFilesRailGroup>(),
|
||||
tailStripGroups: [] as ProducedFilesRailGroup[],
|
||||
}
|
||||
}
|
||||
const coveredTurnIds = new Set<string>()
|
||||
for (const turn of turns) {
|
||||
if (turn.turnId) coveredTurnIds.add(turn.turnId)
|
||||
}
|
||||
const eligibleGroups = agentOutputGroups.filter(
|
||||
(group) => !coveredTurnIds.has(group.turnId),
|
||||
)
|
||||
const { byAssistantMessageId, unmatched } = mapHistoryToProducedFilesGroups(
|
||||
historyMessages,
|
||||
eligibleGroups,
|
||||
)
|
||||
return {
|
||||
filesByAssistantId: byAssistantMessageId,
|
||||
tailStripGroups: unmatched,
|
||||
}
|
||||
}, [agentOutputGroups, isOpenClawAgent, historyMessages, turns])
|
||||
onInitialMessageConsumedRef.current = onInitialMessageConsumed
|
||||
|
||||
const disabled = !agent
|
||||
@@ -171,12 +228,16 @@ function AgentConversationController({
|
||||
agentName={agentName}
|
||||
historyMessages={historyMessages}
|
||||
turns={visibleTurns}
|
||||
stripOnlyTurns={stripOnlyTurns}
|
||||
filesByAssistantId={filesByAssistantId}
|
||||
tailStripGroups={tailStripGroups}
|
||||
streaming={streaming}
|
||||
isInitialLoading={harnessHistoryQuery.isLoading}
|
||||
error={error}
|
||||
hasNextPage={false}
|
||||
isFetchingNextPage={false}
|
||||
onFetchNextPage={() => {}}
|
||||
onOpenOutputsRail={onOpenOutputsRail}
|
||||
onRetry={() => {
|
||||
void harnessHistoryQuery.refetch()
|
||||
}}
|
||||
@@ -287,6 +348,45 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
const isPageVariant = variant === 'page'
|
||||
const backLabel = isPageVariant ? 'Back to agents' : 'Back to home'
|
||||
|
||||
const isOpenClawAgent = harnessAgent?.adapter === 'openclaw'
|
||||
const [outputsRailOpen, setOutputsRailOpen] =
|
||||
useOutputsRailOpen(resolvedAgentId)
|
||||
const railVisible = isOpenClawAgent && outputsRailOpen
|
||||
|
||||
// Deep-link target for the rail. Set when (a) the user clicks
|
||||
// View / +N on an inline file-card strip, or (b) an external nav
|
||||
// arrived with `?outputsTurn=<turnId>`. Cleared by the rail
|
||||
// itself once it has scrolled to + expanded the matching group.
|
||||
const urlOutputsTurn = searchParams.get('outputsTurn')
|
||||
const [focusTurnId, setFocusTurnId] = useState<string | null>(urlOutputsTurn)
|
||||
// If the URL param flips while we're already on this agent, sync.
|
||||
useEffect(() => {
|
||||
if (!urlOutputsTurn) return
|
||||
setFocusTurnId(urlOutputsTurn)
|
||||
if (isOpenClawAgent) setOutputsRailOpen(true)
|
||||
}, [urlOutputsTurn, isOpenClawAgent, setOutputsRailOpen])
|
||||
|
||||
const handleOpenOutputsRail = (turnId?: string | null) => {
|
||||
if (!isOpenClawAgent) return
|
||||
setOutputsRailOpen(true)
|
||||
setFocusTurnId(turnId ?? null)
|
||||
}
|
||||
const handleFocusTurnConsumed = () => {
|
||||
setFocusTurnId(null)
|
||||
if (urlOutputsTurn) {
|
||||
// Drop the URL param so a back-nav doesn't re-trigger the
|
||||
// scroll. `replace: true` keeps history clean.
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.delete('outputsTurn')
|
||||
return next
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const adapterHealth = useMemo<AgentAdapterHealth | null>(() => {
|
||||
const adapterId = harnessAgent?.adapter
|
||||
if (!adapterId) return null
|
||||
@@ -346,13 +446,34 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
onPinToggle={(next) =>
|
||||
handlePinToggle(harnessAgent ?? null, next)
|
||||
}
|
||||
headerExtra={
|
||||
isOpenClawAgent ? (
|
||||
<Button
|
||||
variant={railVisible ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="size-8 rounded-xl"
|
||||
onClick={() => setOutputsRailOpen(!railVisible)}
|
||||
title={railVisible ? 'Hide outputs' : 'Show outputs'}
|
||||
>
|
||||
<PanelRight className="size-4" />
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body grid: rail list + chat. Both columns share the same
|
||||
top edge (the band above) so headers can never drift. */}
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)] lg:grid-cols-[288px_minmax(0,1fr)]">
|
||||
{/* Body grid: rail list + chat (+ outputs rail when an
|
||||
openclaw agent has it open). Columns share the same top
|
||||
edge as the band above so headers can never drift. */}
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)]',
|
||||
railVisible
|
||||
? 'lg:grid-cols-[288px_minmax(0,1fr)_320px]'
|
||||
: 'lg:grid-cols-[288px_minmax(0,1fr)]',
|
||||
)}
|
||||
>
|
||||
<AgentRail
|
||||
agents={harnessAgents}
|
||||
adapters={adapters}
|
||||
@@ -367,13 +488,34 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
agentId={resolvedAgentId}
|
||||
agents={agents}
|
||||
initialMessage={initialMessage}
|
||||
onInitialMessageConsumed={() =>
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
onInitialMessageConsumed={() => {
|
||||
// Preserve the outputsTurn deep-link if present —
|
||||
// dropping all params would erase the rail focus
|
||||
// before it had a chance to consume.
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams()
|
||||
const turn = prev.get('outputsTurn')
|
||||
if (turn) next.set('outputsTurn', turn)
|
||||
return next
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}}
|
||||
agentPathPrefix={agentPathPrefix}
|
||||
createAgentPath={createAgentPath}
|
||||
onOpenOutputsRail={isOpenClawAgent ? handleOpenOutputsRail : null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{railVisible ? (
|
||||
<OutputsRail
|
||||
agentId={resolvedAgentId}
|
||||
onClose={() => setOutputsRailOpen(false)}
|
||||
focusTurnId={focusTurnId}
|
||||
onFocusTurnConsumed={handleFocusTurnConsumed}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -162,12 +162,16 @@ export const AgentCommandHome: FC = () => {
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-5 pt-[max(10vh,24px)] text-center">
|
||||
<div className="space-y-3">
|
||||
<h1 className="font-semibold text-[clamp(2rem,4vw,3.25rem)] leading-tight tracking-tight">
|
||||
What should your agent work on next?
|
||||
<h1 className="font-semibold text-[clamp(2.25rem,4.5vw,3.5rem)] leading-[1.08] tracking-[-0.025em] [text-wrap:balance]">
|
||||
What should your agent{' '}
|
||||
<span className="font-medium text-[var(--accent-orange)] italic">
|
||||
work on
|
||||
</span>{' '}
|
||||
next?
|
||||
</h1>
|
||||
<p className="mx-auto max-w-2xl text-muted-foreground text-sm leading-6">
|
||||
Start with a task, continue a thread, or switch to another
|
||||
agent without leaving the new tab.
|
||||
<p className="mx-auto max-w-2xl text-muted-foreground text-sm leading-6 [text-wrap:pretty]">
|
||||
Start a task, continue a thread, or hand off to a different
|
||||
agent — all without leaving this tab.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,6 +27,14 @@ interface AgentSelectorProps {
|
||||
onSelectAgent: (agent: AgentEntry) => void
|
||||
onCreateAgent?: () => void
|
||||
status?: string
|
||||
/**
|
||||
* `'pill'` renders the filled-pill variant used by the calm
|
||||
* composer on `/home` — bordered, slightly elevated background,
|
||||
* mono agent name, used as the visual anchor on the left of the
|
||||
* footer chip row. Default `'ghost'` keeps the existing flat
|
||||
* shadcn ghost-button trigger used by the chat surface.
|
||||
*/
|
||||
triggerVariant?: 'ghost' | 'pill'
|
||||
}
|
||||
|
||||
function getStatusDot(status?: string) {
|
||||
@@ -42,31 +50,49 @@ export const AgentSelector: FC<AgentSelectorProps> = ({
|
||||
onSelectAgent,
|
||||
onCreateAgent,
|
||||
status,
|
||||
triggerVariant = 'ghost',
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedAgent = agents.find(
|
||||
(agent) => agent.agentId === selectedAgentId,
|
||||
)
|
||||
|
||||
const triggerNode =
|
||||
triggerVariant === 'pill' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex h-6 max-w-[180px] items-center gap-1.5 rounded-full border border-border bg-accent/40 pr-2 pl-2.5 text-[11.5px] text-foreground transition-colors',
|
||||
'hover:border-border hover:bg-accent/70 data-[state=open]:border-border data-[state=open]:bg-accent/70',
|
||||
)}
|
||||
>
|
||||
<span className={cn('size-1.5 rounded-full', getStatusDot(status))} />
|
||||
<span className="truncate font-medium font-mono text-[11.5px] tracking-[-0.01em]">
|
||||
{selectedAgent?.name ?? 'Select agent'}
|
||||
</span>
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
<span className={cn('size-2 rounded-full', getStatusDot(status))} />
|
||||
<span className="max-w-32 truncate">
|
||||
{selectedAgent?.name ?? 'Select agent'}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
<span className={cn('size-2 rounded-full', getStatusDot(status))} />
|
||||
<span className="max-w-32 truncate">
|
||||
{selectedAgent?.name ?? 'Select agent'}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverTrigger asChild>{triggerNode}</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="start" className="w-72 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search agents..." className="h-9" />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Bot, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import { type FC, Fragment, useEffect, useRef } from 'react'
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation'
|
||||
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
|
||||
import type { ProducedFilesRailGroup } from '@/lib/agent-files'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FileCardStrip } from './agent-conversation.file-card-strip'
|
||||
import { ClawChatMessage } from './ClawChatMessage'
|
||||
import { ConversationMessage } from './ConversationMessage'
|
||||
import type { ClawChatMessage as ClawChatMessageModel } from './claw-chat-types'
|
||||
@@ -15,6 +17,29 @@ interface ClawChatProps {
|
||||
agentName: string
|
||||
historyMessages: ClawChatMessageModel[]
|
||||
turns: AgentConversationTurn[]
|
||||
/**
|
||||
* Persisted turns that still need to render their FileCardStrip
|
||||
* because the history items they were filtered against don't
|
||||
* carry produced-files data. Rendered between history and the
|
||||
* live `turns` so the strip lands at the bottom of the
|
||||
* corresponding assistant turn.
|
||||
*/
|
||||
stripOnlyTurns?: AgentConversationTurn[]
|
||||
/**
|
||||
* Maps each assistant history message id → the produced-files
|
||||
* group that came from its turn. Built by
|
||||
* `mapHistoryToProducedFilesGroups` upstream so the strip
|
||||
* renders directly under the matching message instead of
|
||||
* stacking at the conversation tail.
|
||||
*/
|
||||
filesByAssistantId?: Map<string, ProducedFilesRailGroup>
|
||||
/**
|
||||
* Produced-files groups that didn't match any persisted history
|
||||
* pair (e.g. orphaned turns where history loaded after the
|
||||
* group was attributed). Rendered at the conversation tail as
|
||||
* a fallback so the user can still see them.
|
||||
*/
|
||||
tailStripGroups?: ReadonlyArray<ProducedFilesRailGroup>
|
||||
streaming: boolean
|
||||
isInitialLoading: boolean
|
||||
error: Error | null
|
||||
@@ -22,6 +47,8 @@ interface ClawChatProps {
|
||||
isFetchingNextPage: boolean
|
||||
onFetchNextPage: () => void
|
||||
onRetry: () => void
|
||||
/** Wired through to the inline file-card strip on each assistant turn. */
|
||||
onOpenOutputsRail?: ((turnId?: string | null) => void) | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -78,6 +105,9 @@ export const ClawChat: FC<ClawChatProps> = ({
|
||||
agentName,
|
||||
historyMessages,
|
||||
turns,
|
||||
stripOnlyTurns,
|
||||
filesByAssistantId,
|
||||
tailStripGroups,
|
||||
streaming,
|
||||
isInitialLoading,
|
||||
error,
|
||||
@@ -85,6 +115,7 @@ export const ClawChat: FC<ClawChatProps> = ({
|
||||
isFetchingNextPage,
|
||||
onFetchNextPage,
|
||||
onRetry,
|
||||
onOpenOutputsRail,
|
||||
className,
|
||||
}) => {
|
||||
const topSentinelRef = useRef<HTMLDivElement>(null)
|
||||
@@ -147,14 +178,44 @@ export const ClawChat: FC<ClawChatProps> = ({
|
||||
Start of conversation
|
||||
</div>
|
||||
) : null}
|
||||
{historyMessages.map((message) => (
|
||||
<ClawChatMessage key={message.id} message={message} />
|
||||
{historyMessages.map((message) => {
|
||||
const matched = filesByAssistantId?.get(message.id)
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
<ClawChatMessage message={message} />
|
||||
{matched ? (
|
||||
<FileCardStrip
|
||||
turnId={matched.turnId}
|
||||
files={matched.files}
|
||||
onOpenRail={onOpenOutputsRail ?? (() => {})}
|
||||
/>
|
||||
) : null}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{(tailStripGroups ?? []).map((group) => (
|
||||
<FileCardStrip
|
||||
key={`tail-strip-${group.turnId}`}
|
||||
turnId={group.turnId}
|
||||
files={group.files}
|
||||
onOpenRail={onOpenOutputsRail ?? (() => {})}
|
||||
/>
|
||||
))}
|
||||
{(stripOnlyTurns ?? []).map((turn) => (
|
||||
<ConversationMessage
|
||||
key={`strip-${turn.id}`}
|
||||
turn={turn}
|
||||
streaming={false}
|
||||
stripOnly
|
||||
onOpenOutputsRail={onOpenOutputsRail}
|
||||
/>
|
||||
))}
|
||||
{turns.map((turn, index) => (
|
||||
<ConversationMessage
|
||||
key={turn.id}
|
||||
turn={turn}
|
||||
streaming={streaming && index === turns.length - 1}
|
||||
onOpenOutputsRail={onOpenOutputsRail}
|
||||
/>
|
||||
))}
|
||||
{error ? (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ArrowLeft, Home } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatRelativeTime } from '@/entrypoints/app/agents/agent-display.helpers'
|
||||
@@ -14,12 +14,14 @@ import { cn } from '@/lib/utils'
|
||||
interface ConversationHeaderProps {
|
||||
agent: HarnessAgent | null
|
||||
fallbackName: string
|
||||
fallbackAdapter: 'claude' | 'codex' | 'openclaw' | 'unknown'
|
||||
fallbackAdapter: 'claude' | 'codex' | 'openclaw' | 'hermes' | 'unknown'
|
||||
adapterHealth: AgentAdapterHealth | null
|
||||
backLabel: string
|
||||
backTarget: 'home' | 'page'
|
||||
onGoHome: () => void
|
||||
onPinToggle: (next: boolean) => void
|
||||
/** Optional trailing slot — currently used for the Outputs rail toggle. */
|
||||
headerExtra?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +42,7 @@ export const ConversationHeader: FC<ConversationHeaderProps> = ({
|
||||
backTarget,
|
||||
onGoHome,
|
||||
onPinToggle,
|
||||
headerExtra,
|
||||
}) => {
|
||||
const BackIcon = backTarget === 'home' ? Home : ArrowLeft
|
||||
const adapter = agent?.adapter ?? fallbackAdapter
|
||||
@@ -90,16 +93,21 @@ export const ConversationHeader: FC<ConversationHeaderProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<StatusPill
|
||||
status={status}
|
||||
hasActiveTurn={Boolean(agent?.activeTurnId)}
|
||||
/>
|
||||
<div className="flex h-4 items-center text-[11px] text-muted-foreground">
|
||||
<span className="truncate">
|
||||
{metaParts.length > 0 ? metaParts.join(' · ') : '\u00A0'}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<StatusPill
|
||||
status={status}
|
||||
hasActiveTurn={Boolean(agent?.activeTurnId)}
|
||||
/>
|
||||
<div className="flex h-4 items-center text-[11px] text-muted-foreground">
|
||||
<span className="truncate">
|
||||
{metaParts.length > 0 ? metaParts.join(' · ') : '\u00A0'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{headerExtra ? (
|
||||
<div className="flex shrink-0 items-center">{headerExtra}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -164,7 +164,16 @@ function VoiceButton({
|
||||
)
|
||||
}
|
||||
|
||||
function ContextControls({
|
||||
/**
|
||||
* Calm-composer footer shared by both `/home` (`variant="home"`) and
|
||||
* the chat surface at `/agents/:agentId` (`variant="conversation"`).
|
||||
* Pill-shaped chips on an internal dashed divider, with a right-
|
||||
* aligned keyboard hint. The agent selector is conditional via
|
||||
* `showAgentSelector`: home shows it as a filled pill on the left,
|
||||
* the chat surface hides it (the agent is locked once you're in the
|
||||
* conversation).
|
||||
*/
|
||||
function CalmContextControls({
|
||||
agents,
|
||||
onCreateAgent,
|
||||
onSelectAgent,
|
||||
@@ -201,110 +210,128 @@ function ContextControls({
|
||||
)?.is_authenticated
|
||||
})
|
||||
|
||||
const showApps = supports(Feature.MANAGED_MCP_SUPPORT)
|
||||
const showWorkspace = supports(Feature.WORKSPACE_FOLDER_SUPPORT)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-border/40 border-t px-4 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
{showAgentSelector ? (
|
||||
<div className="mx-3 flex items-center gap-1 border-border/60 border-t border-dashed py-2">
|
||||
{showAgentSelector ? (
|
||||
<>
|
||||
<AgentSelector
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={onSelectAgent}
|
||||
onCreateAgent={onCreateAgent}
|
||||
status={status}
|
||||
triggerVariant="pill"
|
||||
/>
|
||||
) : null}
|
||||
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) ? (
|
||||
<WorkspaceSelector>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>{selectedFolder?.name || 'Add workspace'}</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</WorkspaceSelector>
|
||||
) : null}
|
||||
<TabPickerPopover
|
||||
variant="selector"
|
||||
selectedTabs={selectedTabs}
|
||||
onToggleTab={onToggleTab}
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
selectedTabs.length > 0
|
||||
? 'bg-[var(--accent-orange)]! text-white shadow-sm'
|
||||
: 'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mx-1 inline-block h-3.5 w-px shrink-0 bg-border"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{showWorkspace ? (
|
||||
<WorkspaceSelector>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground"
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
<span>Tabs</span>
|
||||
</Button>
|
||||
</TabPickerPopover>
|
||||
<Button
|
||||
<Folder className="size-3" />
|
||||
<span>Workspace</span>
|
||||
<span className="font-mono text-[10.5px] text-muted-foreground/70">
|
||||
{selectedFolder?.name ?? 'none'}
|
||||
</span>
|
||||
</button>
|
||||
</WorkspaceSelector>
|
||||
) : null}
|
||||
<TabPickerPopover
|
||||
variant="selector"
|
||||
selectedTabs={selectedTabs}
|
||||
onToggleTab={onToggleTab}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onAttachClick}
|
||||
disabled={attachDisabled || !attachmentsEnabled}
|
||||
title="Attach files"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] transition-colors data-[state=open]:bg-accent data-[state=open]:text-foreground',
|
||||
selectedTabs.length > 0
|
||||
? 'bg-[var(--accent-orange)] text-white hover:bg-[var(--accent-orange)]/90'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
<span>Attach</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{supports(Feature.MANAGED_MCP_SUPPORT) ? (
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<AppSelector side="bottom">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center -space-x-1.5">
|
||||
<Layers className="size-3" />
|
||||
<span>Tabs</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-[10.5px]',
|
||||
selectedTabs.length > 0
|
||||
? 'text-white/80'
|
||||
: 'text-muted-foreground/70',
|
||||
)}
|
||||
>
|
||||
{selectedTabs.length}
|
||||
</span>
|
||||
</button>
|
||||
</TabPickerPopover>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAttachClick}
|
||||
disabled={attachDisabled || !attachmentsEnabled}
|
||||
title="Attach files"
|
||||
className="inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Paperclip className="size-3" />
|
||||
<span>Attach</span>
|
||||
</button>
|
||||
{showApps ? (
|
||||
<AppSelector side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground"
|
||||
>
|
||||
{connectedManagedServers.length > 0 ? (
|
||||
<span className="flex items-center -space-x-1.5">
|
||||
{connectedManagedServers.slice(0, 4).map((server) => (
|
||||
<div
|
||||
<span
|
||||
key={server.id}
|
||||
className="rounded-full ring-2 ring-card"
|
||||
>
|
||||
<McpServerIcon
|
||||
serverName={server.managedServerName ?? ''}
|
||||
size={16}
|
||||
size={12}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{connectedManagedServers.length > 4 ? (
|
||||
<span className="text-xs">
|
||||
+{connectedManagedServers.length - 4}
|
||||
</span>
|
||||
) : null}
|
||||
<span>Apps</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</AppSelector>
|
||||
</div>
|
||||
</span>
|
||||
) : (
|
||||
<FileText className="size-3" />
|
||||
)}
|
||||
<span>Apps</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
</AppSelector>
|
||||
) : null}
|
||||
<div className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground/70">
|
||||
<kbd className="inline-flex h-4 min-w-4 items-center justify-center rounded border border-border bg-accent/30 px-1 font-mono text-[10px] text-muted-foreground">
|
||||
↵
|
||||
</kbd>
|
||||
<span>to run</span>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<kbd className="inline-flex h-4 min-w-4 items-center justify-center rounded border border-border bg-accent/30 px-1 font-mono text-[10px] text-muted-foreground">
|
||||
⇧
|
||||
</kbd>
|
||||
<kbd className="inline-flex h-4 min-w-4 items-center justify-center rounded border border-border bg-accent/30 px-1 font-mono text-[10px] text-muted-foreground">
|
||||
↵
|
||||
</kbd>
|
||||
<span>new line</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[1.55rem] border border-border/60 bg-card/95 shadow-sm">
|
||||
<div className="overflow-hidden rounded-[1.55rem] border border-border/60 bg-card/95 shadow-sm transition-[border-color,box-shadow] duration-150 focus-within:border-[var(--accent-orange)]/40 focus-within:shadow-[0_0_0_4px_color-mix(in_oklch,var(--accent-orange)_15%,transparent),0_1px_2px_rgba(15,23,42,0.04)]">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -312,7 +339,7 @@ function HomeShell({ children }: { children: ReactNode }) {
|
||||
|
||||
function ConversationShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[1.35rem] border border-border/50 bg-background/95 shadow-[0_10px_30px_rgba(15,23,42,0.06)] backdrop-blur-md">
|
||||
<div className="overflow-hidden rounded-[1.35rem] border border-border/50 bg-background/95 shadow-[0_10px_30px_rgba(15,23,42,0.06)] backdrop-blur-md transition-[border-color,box-shadow] duration-150 focus-within:border-[var(--accent-orange)]/40 focus-within:shadow-[0_0_0_4px_color-mix(in_oklch,var(--accent-orange)_15%,transparent),0_10px_30px_rgba(15,23,42,0.06)]">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -542,7 +569,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
}
|
||||
disabled={disabled || voice.isTranscribing}
|
||||
className={cn(
|
||||
'resize-none border-none bg-transparent px-0 text-[15px] shadow-none focus-visible:ring-0',
|
||||
'resize-none border-none bg-transparent px-0 text-[15px] shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||
'[field-sizing:fixed]',
|
||||
variant === 'home'
|
||||
? 'min-h-[40px] py-2 leading-6'
|
||||
@@ -583,7 +610,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
{voice.error}
|
||||
</div>
|
||||
) : null}
|
||||
<ContextControls
|
||||
<CalmContextControls
|
||||
agents={agents}
|
||||
onCreateAgent={onCreateAgent}
|
||||
onSelectAgent={onSelectAgent}
|
||||
|
||||
@@ -22,10 +22,26 @@ import type {
|
||||
AgentConversationTurn,
|
||||
ToolEntry,
|
||||
} from '@/lib/agent-conversations/types'
|
||||
import { FileCardStrip } from './agent-conversation.file-card-strip'
|
||||
|
||||
interface ConversationMessageProps {
|
||||
turn: AgentConversationTurn
|
||||
streaming: boolean
|
||||
/**
|
||||
* Forwarded to the inline file-card strip's "View" / "+N"
|
||||
* button. Wired up by AgentCommandConversation so the strip can
|
||||
* deep-link straight into the Outputs rail at the matching turn
|
||||
* group. `null` here disables the strip's deep-link affordance
|
||||
* — the cards still open the preview Sheet directly.
|
||||
*/
|
||||
onOpenOutputsRail?: ((turnId?: string | null) => void) | null
|
||||
/**
|
||||
* Render only the trailing FileCardStrip for this turn — used
|
||||
* when the turn's user / assistant text is already rendered
|
||||
* elsewhere (e.g. by `ClawChatMessage` from persisted history)
|
||||
* but the produced-files affordance would otherwise be lost.
|
||||
*/
|
||||
stripOnly?: boolean
|
||||
}
|
||||
|
||||
interface RenderEntry {
|
||||
@@ -88,9 +104,22 @@ function ToolStatusIcon({ status }: { status: ToolEntry['status'] }) {
|
||||
export const ConversationMessage: FC<ConversationMessageProps> = ({
|
||||
turn,
|
||||
streaming,
|
||||
onOpenOutputsRail,
|
||||
stripOnly,
|
||||
}) => {
|
||||
const entries = useMemo(() => buildRenderEntries(turn), [turn])
|
||||
|
||||
if (stripOnly) {
|
||||
if (!turn.producedFiles || turn.producedFiles.length === 0) return null
|
||||
return (
|
||||
<FileCardStrip
|
||||
turnId={turn.turnId ?? null}
|
||||
files={turn.producedFiles}
|
||||
onOpenRail={onOpenOutputsRail ?? (() => {})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Message from="user">
|
||||
@@ -185,6 +214,14 @@ export const ConversationMessage: FC<ConversationMessageProps> = ({
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{turn.producedFiles && turn.producedFiles.length > 0 ? (
|
||||
<FileCardStrip
|
||||
turnId={turn.turnId ?? null}
|
||||
files={turn.producedFiles}
|
||||
onOpenRail={onOpenOutputsRail ?? (() => {})}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!turn.done && turn.parts.length === 0 && streaming && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* @deprecated Replaced by `FileCardStrip` in
|
||||
* `agent-conversation.file-card-strip.tsx`. Kept temporarily so
|
||||
* any in-flight callers don't fail to import; remove in a
|
||||
* follow-up once nothing external references it.
|
||||
*
|
||||
* Compact "Files produced" card rendered under an assistant turn.
|
||||
*/
|
||||
|
||||
import { FileText, Image as ImageIcon, Paperclip } from 'lucide-react'
|
||||
import { type FC, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { basenameOf, formatFileSize, inferFileKind } from '@/lib/agent-files'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FilePreviewSheet } from './agent-conversation.file-preview-sheet'
|
||||
|
||||
export interface ProducedFileLike {
|
||||
id: string
|
||||
path: string
|
||||
size: number
|
||||
}
|
||||
|
||||
interface ArtifactCardProps {
|
||||
files: ReadonlyArray<ProducedFileLike>
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MAX_INLINE_ROWS = 4
|
||||
|
||||
export const ArtifactCard: FC<ArtifactCardProps> = ({ files, className }) => {
|
||||
const [openFileId, setOpenFileId] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const sortedFiles = useMemo(
|
||||
() => [...files].sort((a, b) => a.path.localeCompare(b.path)),
|
||||
[files],
|
||||
)
|
||||
|
||||
if (sortedFiles.length === 0) return null
|
||||
|
||||
const visible = expanded ? sortedFiles : sortedFiles.slice(0, MAX_INLINE_ROWS)
|
||||
const hiddenCount = sortedFiles.length - visible.length
|
||||
const openFile = sortedFiles.find((file) => file.id === openFileId) ?? null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-border/60 bg-card/50 px-3 py-2.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<Paperclip className="size-3.5" />
|
||||
<span className="font-medium text-foreground">
|
||||
{sortedFiles.length === 1
|
||||
? '1 file produced'
|
||||
: `${sortedFiles.length} files produced`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-1">
|
||||
{visible.map((file) => (
|
||||
<li key={file.id}>
|
||||
<ArtifactRow file={file} onOpen={() => setOpenFileId(file.id)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{hiddenCount > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-1.5 h-7 px-2 text-xs"
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
Show {hiddenCount} more
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<FilePreviewSheet
|
||||
fileId={openFile?.id ?? null}
|
||||
filePath={openFile?.path ?? null}
|
||||
open={Boolean(openFileId)}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setOpenFileId(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactRow({
|
||||
file,
|
||||
onOpen,
|
||||
}: {
|
||||
file: ProducedFileLike
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const name = basenameOf(file.path)
|
||||
const kind = inferFileKind(file.path)
|
||||
const Icon = kind === 'image' ? ImageIcon : FileText
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors',
|
||||
'hover:bg-accent/60 focus:bg-accent/60 focus:outline-hidden',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{name}</span>
|
||||
<span className="shrink-0 text-muted-foreground text-xs tabular-nums">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* "Files produced" strip rendered at the bottom of any assistant
|
||||
* turn that produced files (openclaw only). Replaces Phase 5.3's
|
||||
* row-list ArtifactCard with small horizontal cards for a lighter
|
||||
* visual treatment.
|
||||
*
|
||||
* Click semantics:
|
||||
* - Card → opens FilePreviewSheet directly (preview + download).
|
||||
* - View → emits onOpenRail(turnId); the parent opens the rail
|
||||
* and scrolls to the matching turn group.
|
||||
* - +N → same as View (the user is asking to see what was
|
||||
* overflowed).
|
||||
*/
|
||||
|
||||
import { ChevronRight, FileText, Image as ImageIcon } from 'lucide-react'
|
||||
import { type FC, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { basenameOf, formatFileSize, inferFileKind } from '@/lib/agent-files'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FilePreviewSheet } from './agent-conversation.file-preview-sheet'
|
||||
|
||||
export interface CardStripFile {
|
||||
id: string
|
||||
path: string
|
||||
size: number
|
||||
}
|
||||
|
||||
interface FileCardStripProps {
|
||||
/**
|
||||
* The turn id that produced these files. Forwarded to
|
||||
* `onOpenRail` so the rail can scroll/expand the matching group.
|
||||
* Optional because the live `produced_files` event lands before
|
||||
* the harness has stamped a server-issued turn id on the
|
||||
* optimistic turn — in that brief window, View falls back to
|
||||
* just opening the rail at the top.
|
||||
*/
|
||||
turnId?: string | null
|
||||
files: ReadonlyArray<CardStripFile>
|
||||
/** Caller wires this to `setOutputsRailOpen(true)` + deep-link. */
|
||||
onOpenRail: (turnId?: string | null) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 4
|
||||
|
||||
export const FileCardStrip: FC<FileCardStripProps> = ({
|
||||
turnId,
|
||||
files,
|
||||
onOpenRail,
|
||||
className,
|
||||
}) => {
|
||||
const [openFileId, setOpenFileId] = useState<string | null>(null)
|
||||
|
||||
const sortedFiles = useMemo(
|
||||
() => [...files].sort((a, b) => a.path.localeCompare(b.path)),
|
||||
[files],
|
||||
)
|
||||
|
||||
if (sortedFiles.length === 0) return null
|
||||
|
||||
const visible = sortedFiles.slice(0, MAX_VISIBLE)
|
||||
const hiddenCount = sortedFiles.length - visible.length
|
||||
const openFile = sortedFiles.find((file) => file.id === openFileId) ?? null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-border/60 bg-card/50 px-3 py-2.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{sortedFiles.length === 1
|
||||
? 'File produced'
|
||||
: `Files produced (${sortedFiles.length})`}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-7 gap-1 px-2 text-xs"
|
||||
onClick={() => onOpenRail(turnId ?? null)}
|
||||
>
|
||||
View
|
||||
<ChevronRight className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visible.map((file) => (
|
||||
<FileCard
|
||||
key={file.id}
|
||||
file={file}
|
||||
onOpen={() => setOpenFileId(file.id)}
|
||||
/>
|
||||
))}
|
||||
{hiddenCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenRail(turnId ?? null)}
|
||||
className={cn(
|
||||
'flex h-[56px] min-w-[56px] shrink-0 items-center justify-center rounded-lg border border-border/60 px-3 text-muted-foreground text-xs',
|
||||
'transition-colors hover:border-border hover:bg-accent/40 hover:text-foreground',
|
||||
'focus:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--accent-orange)]',
|
||||
)}
|
||||
title={`See ${hiddenCount} more in the Outputs rail`}
|
||||
>
|
||||
+{hiddenCount}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FilePreviewSheet
|
||||
fileId={openFile?.id ?? null}
|
||||
filePath={openFile?.path ?? null}
|
||||
open={Boolean(openFileId)}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setOpenFileId(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileCard({
|
||||
file,
|
||||
onOpen,
|
||||
}: {
|
||||
file: CardStripFile
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const name = basenameOf(file.path)
|
||||
const kind = inferFileKind(file.path)
|
||||
const Icon = kind === 'image' ? ImageIcon : FileText
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
title={file.path}
|
||||
className={cn(
|
||||
'flex h-[56px] w-[140px] shrink-0 flex-col justify-between rounded-lg border border-border/60 bg-background px-2.5 py-1.5 text-left',
|
||||
'transition-colors hover:border-border hover:bg-accent/40',
|
||||
'focus:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--accent-orange)]',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-xs">
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Shared preview drawer used by the inline artifact card AND the
|
||||
* Outputs rail. Branches on the FilePreview discriminated union and
|
||||
* renders the appropriate body. Always opens via a controlled
|
||||
* `open`/`onOpenChange` pair so the parent owns the selected file.
|
||||
*/
|
||||
|
||||
import { Download, FileWarning, Loader2 } from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { MessageResponse } from '@/components/ai-elements/message'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
basenameOf,
|
||||
buildFileDownloadUrl,
|
||||
extensionOf,
|
||||
type FilePreview,
|
||||
formatFileSize,
|
||||
useFilePreview,
|
||||
} from '@/lib/agent-files'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FilePreviewSheetProps {
|
||||
fileId: string | null
|
||||
filePath: string | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown', 'mdx'])
|
||||
|
||||
export const FilePreviewSheet: FC<FilePreviewSheetProps> = ({
|
||||
fileId,
|
||||
filePath,
|
||||
open,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const { baseUrl } = useAgentServerUrl()
|
||||
const { preview, loading, error } = useFilePreview(fileId, open)
|
||||
|
||||
const fileName = filePath ? basenameOf(filePath) : 'File preview'
|
||||
const downloadUrl = useMemo(() => {
|
||||
if (!baseUrl || !fileId) return null
|
||||
return buildFileDownloadUrl(baseUrl, fileId)
|
||||
}, [baseUrl, fileId])
|
||||
|
||||
// Surface preview-load failures in a toast in addition to the
|
||||
// inline error block — the inline UI lives at the bottom of the
|
||||
// sheet and is easy to miss when scrolled into the body.
|
||||
const lastToastedFileIdRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
lastToastedFileIdRef.current = null
|
||||
return
|
||||
}
|
||||
if (!error || !fileId) return
|
||||
if (lastToastedFileIdRef.current === fileId) return
|
||||
lastToastedFileIdRef.current = fileId
|
||||
toast.error('Could not load preview', { description: error.message })
|
||||
}, [open, error, fileId])
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!downloadUrl) {
|
||||
toast.error("Couldn't reach the agent server", {
|
||||
description: 'Reconnect to BrowserOS and try again.',
|
||||
})
|
||||
return
|
||||
}
|
||||
// Manually trigger the download so any future failure (e.g. the
|
||||
// server returns 404 because the file was removed) can be
|
||||
// surfaced via toast — the bare <a download> path swallows
|
||||
// these errors silently.
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadUrl
|
||||
link.download = fileName
|
||||
link.rel = 'noopener'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full flex-col gap-0 p-0 sm:max-w-xl"
|
||||
>
|
||||
<SheetHeader className="border-border/60 border-b px-5 py-4">
|
||||
<SheetTitle className="truncate pr-8">{fileName}</SheetTitle>
|
||||
<SheetDescription className="truncate">
|
||||
{filePath ?? ''}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="px-5 py-4">
|
||||
{loading ? (
|
||||
<PreviewSkeleton />
|
||||
) : error ? (
|
||||
<PreviewError message={error.message} />
|
||||
) : preview ? (
|
||||
<PreviewBody
|
||||
preview={preview}
|
||||
filePath={filePath}
|
||||
downloadUrl={downloadUrl}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{fileId ? (
|
||||
<div className="border-border/60 border-t bg-background/90 px-5 py-3 backdrop-blur">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="w-full gap-2"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Loading preview...
|
||||
</div>
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewError({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-sm">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<FileWarning className="size-4" />
|
||||
Could not load preview
|
||||
</div>
|
||||
<p className="text-destructive/80 text-xs">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewBody({
|
||||
preview,
|
||||
filePath,
|
||||
downloadUrl,
|
||||
}: {
|
||||
preview: FilePreview
|
||||
filePath: string | null
|
||||
downloadUrl: string | null
|
||||
}) {
|
||||
if (preview.kind === 'missing') {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/40 px-4 py-6 text-center text-muted-foreground text-sm">
|
||||
This file is no longer in the workspace. The agent may have moved or
|
||||
deleted it after the turn finished.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.kind === 'image') {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<PreviewMeta preview={preview} />
|
||||
<div className="overflow-hidden rounded-lg border border-border/60 bg-muted/30">
|
||||
<img
|
||||
src={preview.dataUrl}
|
||||
alt={filePath ?? 'preview'}
|
||||
className="block max-h-[60vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.kind === 'pdf') {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<PreviewMeta preview={preview} />
|
||||
<div className="rounded-lg border border-border/60 bg-muted/40 px-4 py-6 text-center text-muted-foreground text-sm">
|
||||
PDF previews aren't supported inline yet. Use Download to open this
|
||||
file in your default PDF viewer.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.kind === 'binary') {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<PreviewMeta preview={preview} />
|
||||
<div className="rounded-lg border border-border/60 bg-muted/40 px-4 py-6 text-center text-muted-foreground text-sm">
|
||||
No inline preview for this file type.
|
||||
{downloadUrl ? ' Use Download to save it locally.' : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <TextPreviewBody preview={preview} filePath={filePath} />
|
||||
}
|
||||
|
||||
function TextPreviewBody({
|
||||
preview,
|
||||
filePath,
|
||||
}: {
|
||||
preview: Extract<FilePreview, { kind: 'text' }>
|
||||
filePath: string | null
|
||||
}) {
|
||||
const ext = filePath ? extensionOf(filePath).toLowerCase() : ''
|
||||
const renderAsMarkdown = MARKDOWN_EXTENSIONS.has(ext)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<PreviewMeta preview={preview} />
|
||||
{renderAsMarkdown ? (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-sm dark:prose-invert max-w-none break-words rounded-lg border border-border/60 bg-muted/30 px-4 py-3',
|
||||
"[&_[data-streamdown='code-block']]:!w-full [&_[data-streamdown='code-block']]:overflow-x-auto",
|
||||
)}
|
||||
>
|
||||
<MessageResponse mode="static" parseIncompleteMarkdown={false}>
|
||||
{preview.snippet}
|
||||
</MessageResponse>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="overflow-x-auto rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-xs leading-relaxed">
|
||||
<code className="font-mono text-foreground">{preview.snippet}</code>
|
||||
</pre>
|
||||
)}
|
||||
{preview.truncated ? (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Showing the first part of this file. Download to see the full
|
||||
contents.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewMeta({
|
||||
preview,
|
||||
}: {
|
||||
preview: Exclude<FilePreview, { kind: 'missing' }>
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground text-xs">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatFileSize(preview.size)}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono">{preview.mimeType || 'unknown'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Per-agent right-side "Outputs" panel. Lists every file the harness
|
||||
* has attributed to this agent, grouped by the turn that produced
|
||||
* them. Click a row to open the shared preview Sheet.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - Open/closed state is controlled by the parent and persisted via
|
||||
* `useOutputsRailOpen(agentId)` so each agent remembers its
|
||||
* preference independently.
|
||||
* - Data refreshes whenever a turn finishes (the conversation hook
|
||||
* fires `useInvalidateAgentOutputs` from its finally block).
|
||||
* - Manual "Refresh" button is wired to `useRefreshAgentOutputs`
|
||||
* for users who navigate in mid-turn.
|
||||
*/
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Inbox,
|
||||
Loader2,
|
||||
PanelRightClose,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
basenameOf,
|
||||
formatFileSize,
|
||||
inferFileKind,
|
||||
type ProducedFilesRailGroup,
|
||||
useAgentOutputs,
|
||||
useRefreshAgentOutputs,
|
||||
} from '@/lib/agent-files'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FilePreviewSheet } from './agent-conversation.file-preview-sheet'
|
||||
|
||||
interface OutputsRailProps {
|
||||
agentId: string
|
||||
onClose: () => void
|
||||
/**
|
||||
* When set, the rail scrolls the matching `RailTurnGroup` into
|
||||
* view and force-opens its `Collapsible`. Used by the inline
|
||||
* file-card strip's "View" / "+N" deep-link path. Cleared by
|
||||
* the parent (via `onFocusTurnConsumed`) once the rail has
|
||||
* acknowledged the deep-link so subsequent renders don't keep
|
||||
* re-scrolling the same group.
|
||||
*/
|
||||
focusTurnId?: string | null
|
||||
onFocusTurnConsumed?: () => void
|
||||
}
|
||||
|
||||
const RAIL_LOCAL_STORAGE_PREFIX = 'browseros:outputs-rail:'
|
||||
|
||||
/**
|
||||
* Controlled open/close state with per-agent localStorage memory.
|
||||
* Returns a tuple compatible with React's useState shape so the
|
||||
* parent can pass it straight into the rail without an extra effect.
|
||||
*/
|
||||
export function useOutputsRailOpen(
|
||||
agentId: string,
|
||||
): [boolean, (next: boolean) => void] {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !agentId) return
|
||||
try {
|
||||
const stored = window.localStorage.getItem(
|
||||
`${RAIL_LOCAL_STORAGE_PREFIX}${agentId}`,
|
||||
)
|
||||
setOpen(stored === '1')
|
||||
} catch {
|
||||
// localStorage may be unavailable (private mode, locked-down
|
||||
// contexts) — fall back to closed.
|
||||
}
|
||||
}, [agentId])
|
||||
|
||||
const update = (next: boolean) => {
|
||||
setOpen(next)
|
||||
if (typeof window === 'undefined' || !agentId) return
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
`${RAIL_LOCAL_STORAGE_PREFIX}${agentId}`,
|
||||
next ? '1' : '0',
|
||||
)
|
||||
} catch {
|
||||
// Best-effort persistence.
|
||||
}
|
||||
}
|
||||
|
||||
return [open, update]
|
||||
}
|
||||
|
||||
export const OutputsRail: FC<OutputsRailProps> = ({
|
||||
agentId,
|
||||
onClose,
|
||||
focusTurnId,
|
||||
onFocusTurnConsumed,
|
||||
}) => {
|
||||
const { groups, loading, error } = useAgentOutputs(agentId)
|
||||
const refresh = useRefreshAgentOutputs(agentId)
|
||||
|
||||
const [openFile, setOpenFile] = useState<{
|
||||
id: string
|
||||
path: string
|
||||
} | null>(null)
|
||||
|
||||
const totalFiles = useMemo(
|
||||
() => groups.reduce((sum, group) => sum + group.files.length, 0),
|
||||
[groups],
|
||||
)
|
||||
|
||||
return (
|
||||
<aside className="flex h-full min-h-0 w-full flex-col border-border/50 border-l bg-background">
|
||||
<header className="flex shrink-0 items-center gap-2 border-border/50 border-b px-3 py-3">
|
||||
<span className="font-semibold text-[13px] uppercase tracking-wide">
|
||||
Outputs
|
||||
</span>
|
||||
{totalFiles > 0 ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{totalFiles}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() =>
|
||||
refresh.mutate(undefined, {
|
||||
onError: (err) =>
|
||||
toast.error('Refresh failed', {
|
||||
description:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
})
|
||||
}
|
||||
disabled={refresh.isPending}
|
||||
title="Refresh"
|
||||
>
|
||||
{refresh.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={onClose}
|
||||
title="Hide outputs"
|
||||
>
|
||||
<PanelRightClose className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="px-2 py-2">
|
||||
{loading && groups.length === 0 ? (
|
||||
<RailSkeleton />
|
||||
) : error ? (
|
||||
<RailError message={error.message} />
|
||||
) : groups.length === 0 ? (
|
||||
<RailEmpty />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{groups.map((group) => (
|
||||
<li key={group.turnId}>
|
||||
<RailTurnGroup
|
||||
group={group}
|
||||
focused={
|
||||
Boolean(focusTurnId) && focusTurnId === group.turnId
|
||||
}
|
||||
onFocusConsumed={onFocusTurnConsumed}
|
||||
onOpenFile={(file) =>
|
||||
setOpenFile({ id: file.id, path: file.path })
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<FilePreviewSheet
|
||||
fileId={openFile?.id ?? null}
|
||||
filePath={openFile?.path ?? null}
|
||||
open={Boolean(openFile)}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setOpenFile(null)
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function RailTurnGroup({
|
||||
group,
|
||||
focused,
|
||||
onFocusConsumed,
|
||||
onOpenFile,
|
||||
}: {
|
||||
group: ProducedFilesRailGroup
|
||||
focused: boolean
|
||||
onFocusConsumed?: () => void
|
||||
onOpenFile: (file: { id: string; path: string }) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const headerLabel = group.turnPrompt.trim() || 'Turn'
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Deep-link consumption: when the parent passes `focused=true`,
|
||||
// expand the collapsible (in case the user had collapsed it
|
||||
// earlier) and scroll into view. Fire `onFocusConsumed` so the
|
||||
// parent can drop the URL param and we don't re-scroll on every
|
||||
// render after that.
|
||||
useEffect(() => {
|
||||
if (!focused) return
|
||||
setOpen(true)
|
||||
containerRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
onFocusConsumed?.()
|
||||
}, [focused, onFocusConsumed])
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-muted-foreground text-xs',
|
||||
'transition-colors hover:bg-accent/40 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="size-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{headerLabel}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">{group.files.length}</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ul className="mt-1 ml-1 flex flex-col gap-0.5 border-border/40 border-l pl-2">
|
||||
{group.files.map((file) => (
|
||||
<li key={file.id}>
|
||||
<RailFileRow file={file} onOpen={() => onOpenFile(file)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RailFileRow({
|
||||
file,
|
||||
onOpen,
|
||||
}: {
|
||||
file: ProducedFilesRailGroup['files'][number]
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const name = basenameOf(file.path)
|
||||
const kind = inferFileKind(file.path)
|
||||
const Icon = kind === 'image' ? ImageIcon : FileText
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left text-xs transition-colors',
|
||||
'hover:bg-accent/60 focus:bg-accent/60 focus:outline-hidden',
|
||||
)}
|
||||
title={file.path}
|
||||
>
|
||||
<Icon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{name}</span>
|
||||
<span className="shrink-0 text-muted-foreground tabular-nums">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function RailSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-1.5 py-1">
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RailEmpty() {
|
||||
return (
|
||||
<div className="mx-2 my-3 flex flex-col items-center gap-1.5 rounded-lg border border-border/60 border-dashed bg-muted/20 px-3 py-6 text-center text-muted-foreground text-xs">
|
||||
<Inbox className="size-4" />
|
||||
<p className="font-medium">No outputs yet</p>
|
||||
<p className="text-[11px] text-muted-foreground/70 leading-snug">
|
||||
Files this agent creates will appear here, grouped by the turn that made
|
||||
them.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RailError({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="mx-2 my-3 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-xs">
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
|
||||
import type { ProducedFilesRailGroup } from '@/lib/agent-files'
|
||||
|
||||
export type ClawChatRole = 'user' | 'assistant'
|
||||
|
||||
@@ -234,6 +235,30 @@ export function filterTurnsPersistedInHistory(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted turns that still carry `producedFiles` — once history
|
||||
* reloads, the assistant text is rendered by `ClawChatMessage` and
|
||||
* the optimistic turn is filtered out by
|
||||
* `filterTurnsPersistedInHistory`. The historical message has no
|
||||
* `producedFiles` field (history items don't carry that), so the
|
||||
* inline file-card strip would vanish on history reload.
|
||||
*
|
||||
* Returning these here lets the caller render a strip-only entry
|
||||
* after the corresponding history bubble — full message stays as
|
||||
* the persisted history pair, but the produced-files affordance
|
||||
* survives.
|
||||
*/
|
||||
export function selectStripOnlyTurns(
|
||||
turns: AgentConversationTurn[],
|
||||
historyMessages: ClawChatMessage[],
|
||||
): AgentConversationTurn[] {
|
||||
return turns.filter(
|
||||
(turn) =>
|
||||
Boolean(turn.producedFiles && turn.producedFiles.length > 0) &&
|
||||
isTurnPersistedInHistory(turn, historyMessages),
|
||||
)
|
||||
}
|
||||
|
||||
function isTurnPersistedInHistory(
|
||||
turn: AgentConversationTurn,
|
||||
historyMessages: ClawChatMessage[],
|
||||
@@ -285,3 +310,59 @@ function getClawMessageText(message: ClawChatMessage): string {
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function firstNonBlankLine(value: string): string {
|
||||
for (const raw of value.split('\n')) {
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed) return trimmed
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Map each assistant history message to the produced-files group
|
||||
* that came from its turn. Match key is `group.turnPrompt` (first
|
||||
* non-blank line of the user prompt that initiated the turn) vs.
|
||||
* the first non-blank line of the user message that immediately
|
||||
* preceded this assistant message — the same shape the server
|
||||
* emits when storing turnPrompt.
|
||||
*
|
||||
* Walks history forward (oldest-first per `flattenHistoryPages`)
|
||||
* and consumes groups in chronological order. A group can only
|
||||
* match once — if two turns share the same prompt the earlier
|
||||
* one wins, and the later assistant message stays unassociated
|
||||
* (those land back in `tailStripGroups` at the conversation tail).
|
||||
*/
|
||||
export function mapHistoryToProducedFilesGroups(
|
||||
historyMessages: ClawChatMessage[],
|
||||
groups: ReadonlyArray<ProducedFilesRailGroup>,
|
||||
): {
|
||||
byAssistantMessageId: Map<string, ProducedFilesRailGroup>
|
||||
unmatched: ProducedFilesRailGroup[]
|
||||
} {
|
||||
const byAssistantMessageId = new Map<string, ProducedFilesRailGroup>()
|
||||
if (groups.length === 0) {
|
||||
return { byAssistantMessageId, unmatched: [] }
|
||||
}
|
||||
// Oldest-first so the iteration order matches history.
|
||||
const remaining = [...groups].sort((a, b) => a.createdAt - b.createdAt)
|
||||
|
||||
let pendingPrompt: string | null = null
|
||||
for (const message of historyMessages) {
|
||||
if (message.role === 'user') {
|
||||
pendingPrompt = firstNonBlankLine(getClawMessageText(message))
|
||||
continue
|
||||
}
|
||||
if (message.role !== 'assistant' || !pendingPrompt) continue
|
||||
const matchIndex = remaining.findIndex(
|
||||
(group) => group.turnPrompt === pendingPrompt,
|
||||
)
|
||||
if (matchIndex >= 0) {
|
||||
const [match] = remaining.splice(matchIndex, 1)
|
||||
byAssistantMessageId.set(message.id, match)
|
||||
}
|
||||
pendingPrompt = null
|
||||
}
|
||||
|
||||
return { byAssistantMessageId, unmatched: remaining }
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpe
|
||||
import type {
|
||||
AgentConversationTurn,
|
||||
AssistantPart,
|
||||
ConversationTurnFile,
|
||||
ToolEntry,
|
||||
UserAttachmentPreview,
|
||||
} from '@/lib/agent-conversations/types'
|
||||
import { useInvalidateAgentOutputs } from '@/lib/agent-files'
|
||||
import type { ServerAttachmentPayload } from '@/lib/attachments'
|
||||
import { consumeSSEStream } from '@/lib/sse'
|
||||
import { buildToolLabel } from '@/lib/tool-labels'
|
||||
@@ -53,6 +55,12 @@ export function useAgentConversation(
|
||||
) {
|
||||
const [turns, setTurns] = useState<AgentConversationTurn[]>([])
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const invalidateAgentOutputs = useInvalidateAgentOutputs()
|
||||
// Stable ref so the resume effect doesn't re-subscribe on every
|
||||
// render (the hook's returned callable is freshly closured each
|
||||
// time, but the underlying queryClient is stable).
|
||||
const invalidateAgentOutputsRef = useRef(invalidateAgentOutputs)
|
||||
invalidateAgentOutputsRef.current = invalidateAgentOutputs
|
||||
const sessionKeyRef = useRef(options.sessionKey ?? '')
|
||||
const historyRef = useRef<OpenClawChatHistoryMessage[]>(options.history ?? [])
|
||||
const textAccRef = useRef('')
|
||||
@@ -152,6 +160,17 @@ export function useAgentConversation(
|
||||
})
|
||||
}
|
||||
|
||||
const setProducedFilesOnCurrentTurn = (files: ConversationTurnFile[]) => {
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
// Replace, don't merge: the server's diff is authoritative for
|
||||
// the just-completed turn — duplicate events shouldn't grow the
|
||||
// list, and a re-attribution should overwrite an earlier one.
|
||||
return [...prev.slice(0, -1), { ...last, producedFiles: files }]
|
||||
})
|
||||
}
|
||||
|
||||
const upsertAgentHarnessTool = (event: AgentHarnessStreamEvent) => {
|
||||
if (event.type !== 'tool_call') return
|
||||
const rawName = event.title || event.rawType || 'tool call'
|
||||
@@ -208,6 +227,9 @@ export function useAgentConversation(
|
||||
case 'tool_call':
|
||||
upsertAgentHarnessTool(event)
|
||||
break
|
||||
case 'produced_files':
|
||||
setProducedFilesOnCurrentTurn(event.files)
|
||||
break
|
||||
case 'done':
|
||||
markCurrentTurnDone()
|
||||
break
|
||||
@@ -259,6 +281,7 @@ export function useAgentConversation(
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
turnId: active.turnId,
|
||||
userText: active.prompt ?? '',
|
||||
parts: [],
|
||||
done: false,
|
||||
@@ -304,9 +327,14 @@ export function useAgentConversation(
|
||||
// When `cancelled` is true the next run will set these
|
||||
// itself, so resetting here would only cause a brief flicker.
|
||||
if (!cancelled && weStartedStream) {
|
||||
const finishedTurnId = turnIdRef.current
|
||||
turnIdRef.current = null
|
||||
lastSeqRef.current = null
|
||||
setStreaming(false)
|
||||
void invalidateAgentOutputsRef.current(
|
||||
agentId,
|
||||
finishedTurnId ?? undefined,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,6 +346,60 @@ export function useAgentConversation(
|
||||
}
|
||||
}, [agentId, activeTurnIdDep])
|
||||
|
||||
/**
|
||||
* Send the chat request and follow the 409-active-turn redirect
|
||||
* once. Pulled out of `send` to keep its cognitive complexity in
|
||||
* check — the retry adds a branch that biome counts heavily.
|
||||
*/
|
||||
const openSendStream = async (
|
||||
targetAgentId: string,
|
||||
text: string,
|
||||
attachments: ServerAttachmentPayload[],
|
||||
signal: AbortSignal,
|
||||
): Promise<Response> => {
|
||||
const initial = await chatWithHarnessAgent(
|
||||
targetAgentId,
|
||||
text,
|
||||
signal,
|
||||
attachments,
|
||||
)
|
||||
if (initial.status !== 409) return initial
|
||||
// 409 means the server already has an active turn for this agent
|
||||
// (a previous tab kicked one off and we're a fresh mount that
|
||||
// missed the resume window). Attach to it instead of double-sending.
|
||||
const body = (await initial.json()) as { turnId?: string }
|
||||
if (!body.turnId) return initial
|
||||
return attachToHarnessTurn(targetAgentId, {
|
||||
turnId: body.turnId,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull session-key / turn-id off response headers and propagate to
|
||||
* refs + the optimistic turn. Stamping `turnId` here lets the
|
||||
* inline artifact card fall back to /files/turn/<id> on a resumed
|
||||
* mount that missed the live `produced_files` event.
|
||||
*/
|
||||
const applyResponseHeadersToTurn = (response: Response) => {
|
||||
const responseSessionKey =
|
||||
response.headers.get('X-Session-Key') ??
|
||||
response.headers.get('X-Session-Id')
|
||||
if (responseSessionKey) {
|
||||
sessionKeyRef.current = responseSessionKey
|
||||
onSessionKeyChangeRef.current?.(responseSessionKey)
|
||||
}
|
||||
const responseTurnId = response.headers.get('X-Turn-Id')
|
||||
if (!responseTurnId) return
|
||||
turnIdRef.current = responseTurnId
|
||||
lastSeqRef.current = null
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
return [...prev.slice(0, -1), { ...last, turnId: responseTurnId }]
|
||||
})
|
||||
}
|
||||
|
||||
const send = async (input: string | SendInput) => {
|
||||
const normalized: SendInput =
|
||||
typeof input === 'string' ? { text: input } : input
|
||||
@@ -346,37 +428,13 @@ export function useAgentConversation(
|
||||
streamAbortRef.current = abortController
|
||||
|
||||
try {
|
||||
let response = await chatWithHarnessAgent(
|
||||
const response = await openSendStream(
|
||||
agentId,
|
||||
trimmed,
|
||||
abortController.signal,
|
||||
attachments,
|
||||
abortController.signal,
|
||||
)
|
||||
// 409 means the server already has an active turn for this
|
||||
// agent (e.g. a previous tab kicked one off and we're a fresh
|
||||
// mount that missed the resume window). Attach to it instead of
|
||||
// double-sending.
|
||||
if (response.status === 409) {
|
||||
const body = (await response.json()) as { turnId?: string }
|
||||
if (body.turnId) {
|
||||
response = await attachToHarnessTurn(agentId, {
|
||||
turnId: body.turnId,
|
||||
signal: abortController.signal,
|
||||
})
|
||||
}
|
||||
}
|
||||
const responseSessionKey =
|
||||
response.headers.get('X-Session-Key') ??
|
||||
response.headers.get('X-Session-Id')
|
||||
if (responseSessionKey) {
|
||||
sessionKeyRef.current = responseSessionKey
|
||||
onSessionKeyChangeRef.current?.(responseSessionKey)
|
||||
}
|
||||
const responseTurnId = response.headers.get('X-Turn-Id')
|
||||
if (responseTurnId) {
|
||||
turnIdRef.current = responseTurnId
|
||||
lastSeqRef.current = null
|
||||
}
|
||||
applyResponseHeadersToTurn(response)
|
||||
if (!response.ok) {
|
||||
const err = await response.text()
|
||||
updateCurrentTurnParts((parts) => [
|
||||
@@ -404,10 +462,15 @@ export function useAgentConversation(
|
||||
if (streamAbortRef.current === abortController) {
|
||||
streamAbortRef.current = null
|
||||
}
|
||||
// Capture before nulling — the invalidation needs the turn id so
|
||||
// useAgentTurnFiles consumers also flush, not just the agent-wide
|
||||
// rail query.
|
||||
const finishedTurnId = turnIdRef.current
|
||||
turnIdRef.current = null
|
||||
lastSeqRef.current = null
|
||||
onCompleteRef.current?.()
|
||||
setStreaming(false)
|
||||
void invalidateAgentOutputs(agentId, finishedTurnId ?? undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bot, Cpu, Sparkles } from 'lucide-react'
|
||||
import { Bot, Cpu, Sparkles, Wand2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import type { HarnessAgentAdapter } from './agent-harness-types'
|
||||
|
||||
@@ -23,6 +23,9 @@ export const AdapterIcon: FC<AdapterIconProps> = ({ adapter, className }) => {
|
||||
case 'openclaw':
|
||||
// OpenClaw — bot/automation framing.
|
||||
return <Bot className={className} aria-label="OpenClaw" />
|
||||
case 'hermes':
|
||||
// Hermes — messenger god framing, wand evokes the agentic conjuring.
|
||||
return <Wand2 className={className} aria-label="Hermes" />
|
||||
default:
|
||||
return <Bot className={className} aria-label="Agent" />
|
||||
}
|
||||
@@ -36,6 +39,8 @@ export function adapterLabel(adapter: HarnessAgentAdapter | 'unknown'): string {
|
||||
return 'Codex'
|
||||
case 'openclaw':
|
||||
return 'OpenClaw'
|
||||
case 'hermes':
|
||||
return 'Hermes'
|
||||
default:
|
||||
return 'Agent'
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ function inferAdapterFromLabel(label: string): HarnessAgentAdapter | 'unknown' {
|
||||
if (lower === 'claude code') return 'claude'
|
||||
if (lower === 'codex') return 'codex'
|
||||
if (lower === 'openclaw') return 'openclaw'
|
||||
if (lower === 'hermes') return 'hermes'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createAgentPageActions } from './agents-page-actions'
|
||||
import {
|
||||
useDefaultAgentName,
|
||||
useHarnessAgentDefaults,
|
||||
useHermesProviderSelection,
|
||||
useOpenClawProviderSelection,
|
||||
} from './agents-page-hooks'
|
||||
import {
|
||||
@@ -106,6 +107,7 @@ export const AgentsPage: FC = () => {
|
||||
)
|
||||
const [harnessModelId, setHarnessModelId] = useState('')
|
||||
const [harnessReasoningEffort, setHarnessReasoningEffort] = useState('')
|
||||
const [createHermesProviderId, setCreateHermesProviderId] = useState('')
|
||||
const [showTerminal, setShowTerminal] = useState(false)
|
||||
const [cliAuthModalOpen, setCliAuthModalOpen] = useState(false)
|
||||
const [pageError, setPageError] = useState<string | null>(null)
|
||||
@@ -133,6 +135,14 @@ export const AgentsPage: FC = () => {
|
||||
cliAuthModalOpen,
|
||||
setCliAuthModalOpen,
|
||||
})
|
||||
const { selectableHermesProviders } = useHermesProviderSelection({
|
||||
providers,
|
||||
defaultProviderId,
|
||||
createOpen,
|
||||
createRuntime,
|
||||
createHermesProviderId,
|
||||
setCreateHermesProviderId,
|
||||
})
|
||||
useDefaultAgentName(createOpen, setNewName)
|
||||
useHarnessAgentDefaults({
|
||||
adapters,
|
||||
@@ -226,11 +236,13 @@ export const AgentsPage: FC = () => {
|
||||
createAgentPageActions({
|
||||
createProviderId,
|
||||
createRuntime,
|
||||
createHermesProviderId,
|
||||
harnessModelId,
|
||||
harnessReasoningEffort,
|
||||
navigate,
|
||||
newName,
|
||||
selectableOpenClawProviders,
|
||||
selectableHermesProviders,
|
||||
setupProviderId,
|
||||
createHarnessAgent: createHarnessAgent.mutateAsync,
|
||||
createOpenClawAgent,
|
||||
@@ -386,6 +398,8 @@ export const AgentsPage: FC = () => {
|
||||
harnessAdapterId={harnessAdapterId}
|
||||
harnessModelId={harnessModelId}
|
||||
harnessReasoningEffort={harnessReasoningEffort}
|
||||
hermesProviders={selectableHermesProviders}
|
||||
hermesSelectedProviderId={createHermesProviderId}
|
||||
name={newName}
|
||||
open={createOpen}
|
||||
providers={selectableOpenClawProviders}
|
||||
@@ -401,12 +415,14 @@ export const AgentsPage: FC = () => {
|
||||
if (!open) {
|
||||
setCreateError(null)
|
||||
createHarnessAgent.reset()
|
||||
setCreateHermesProviderId('')
|
||||
}
|
||||
}}
|
||||
onRuntimeChange={setCreateRuntime}
|
||||
onHarnessAdapterChange={handleHarnessAdapterChange}
|
||||
onHarnessModelChange={setHarnessModelId}
|
||||
onHarnessReasoningChange={setHarnessReasoningEffort}
|
||||
onHermesProviderChange={setCreateHermesProviderId}
|
||||
onNameChange={setNewName}
|
||||
onProviderChange={setCreateProviderId}
|
||||
/>
|
||||
|
||||
@@ -40,6 +40,8 @@ interface NewAgentDialogProps {
|
||||
harnessAdapterId: HarnessAgentAdapter
|
||||
harnessModelId: string
|
||||
harnessReasoningEffort: string
|
||||
hermesProviders: ProviderOption[]
|
||||
hermesSelectedProviderId: string
|
||||
name: string
|
||||
open: boolean
|
||||
providers: ProviderOption[]
|
||||
@@ -55,6 +57,7 @@ interface NewAgentDialogProps {
|
||||
onHarnessAdapterChange: (adapter: HarnessAgentAdapter) => void
|
||||
onHarnessModelChange: (modelId: string) => void
|
||||
onHarnessReasoningChange: (reasoningEffort: string) => void
|
||||
onHermesProviderChange: (providerId: string) => void
|
||||
onNameChange: (name: string) => void
|
||||
onProviderChange: (providerId: string) => void
|
||||
}
|
||||
@@ -69,6 +72,8 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
harnessAdapterId,
|
||||
harnessModelId,
|
||||
harnessReasoningEffort,
|
||||
hermesProviders,
|
||||
hermesSelectedProviderId,
|
||||
name,
|
||||
open,
|
||||
providers,
|
||||
@@ -84,22 +89,29 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
onHarnessAdapterChange,
|
||||
onHarnessModelChange,
|
||||
onHarnessReasoningChange,
|
||||
onHermesProviderChange,
|
||||
onNameChange,
|
||||
onProviderChange,
|
||||
}) => {
|
||||
const selectedHarnessAdapter =
|
||||
adapters.find((adapter) => adapter.id === harnessAdapterId) ?? adapters[0]
|
||||
const isHarnessRuntime = createRuntime !== 'openclaw'
|
||||
const isHermesRuntime = createRuntime === 'hermes'
|
||||
const isClassicHarnessRuntime = isHarnessRuntime && !isHermesRuntime
|
||||
const openClawBlocked = createRuntime === 'openclaw' && !canManageOpenClaw
|
||||
const cliBlocked =
|
||||
createRuntime === 'openclaw' &&
|
||||
!!selectedCliProvider &&
|
||||
!cliAuthStatus?.loggedIn
|
||||
const hermesBlocked =
|
||||
isHermesRuntime &&
|
||||
(hermesProviders.length === 0 || !hermesSelectedProviderId)
|
||||
const canCreate =
|
||||
Boolean(name.trim()) &&
|
||||
!creating &&
|
||||
!openClawBlocked &&
|
||||
!cliBlocked &&
|
||||
!hermesBlocked &&
|
||||
(createRuntime === 'openclaw'
|
||||
? providers.length > 0
|
||||
: Boolean(selectedHarnessAdapter))
|
||||
@@ -143,7 +155,8 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
if (
|
||||
value === 'openclaw' ||
|
||||
value === 'claude' ||
|
||||
value === 'codex'
|
||||
value === 'codex' ||
|
||||
value === 'hermes'
|
||||
) {
|
||||
onRuntimeChange(value)
|
||||
if (value !== 'openclaw') onHarnessAdapterChange(value)
|
||||
@@ -196,7 +209,16 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isHarnessRuntime ? (
|
||||
{isHermesRuntime ? (
|
||||
<ProviderSelector
|
||||
providers={hermesProviders}
|
||||
defaultProviderId={defaultProviderId}
|
||||
selectedId={hermesSelectedProviderId}
|
||||
onSelect={onHermesProviderChange}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isClassicHarnessRuntime ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="harness-model">Model</Label>
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import type { AgentEntry } from './useOpenClaw'
|
||||
|
||||
export type HarnessAgentAdapter = 'claude' | 'codex' | 'openclaw'
|
||||
export type HarnessAgentAdapter = 'claude' | 'codex' | 'openclaw' | 'hermes'
|
||||
|
||||
/**
|
||||
* One file the harness attributed to the assistant turn that just
|
||||
* finished. Mirrors the server-side `ProducedFileEventEntry` shape so
|
||||
* the inline artifact card can render alongside the streamed text the
|
||||
* user just watched complete. Only present for openclaw adapter
|
||||
* turns; claude / codex don't produce these events in v1.
|
||||
*/
|
||||
export interface HarnessProducedFile {
|
||||
id: string
|
||||
/** Workspace-relative POSIX path. */
|
||||
path: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
export type AgentHarnessStreamEvent =
|
||||
| {
|
||||
@@ -22,6 +37,10 @@ export type AgentHarnessStreamEvent =
|
||||
text: string
|
||||
rawType?: string
|
||||
}
|
||||
| {
|
||||
type: 'produced_files'
|
||||
files: HarnessProducedFile[]
|
||||
}
|
||||
| {
|
||||
type: 'done'
|
||||
text?: string
|
||||
@@ -111,6 +130,17 @@ export interface CreateHarnessAgentInput {
|
||||
adapter: HarnessAgentAdapter
|
||||
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.
|
||||
*/
|
||||
providerType?: string
|
||||
/** Hermes-only — API key paired with `providerType`. */
|
||||
apiKey?: string
|
||||
/** Hermes-only — base URL for the `custom` provider. */
|
||||
baseUrl?: string
|
||||
}
|
||||
|
||||
export interface HarnessHistoryReasoning {
|
||||
|
||||
@@ -20,17 +20,22 @@ import type {
|
||||
export interface AgentPageActionInput {
|
||||
createProviderId: string
|
||||
createRuntime: CreateAgentRuntime
|
||||
createHermesProviderId: string
|
||||
harnessModelId: string
|
||||
harnessReasoningEffort: string
|
||||
navigate: NavigateFunction
|
||||
newName: string
|
||||
selectableOpenClawProviders: ProviderOption[]
|
||||
selectableHermesProviders: ProviderOption[]
|
||||
setupProviderId: string
|
||||
createHarnessAgent: (input: {
|
||||
name: string
|
||||
adapter: HarnessAgentAdapter
|
||||
modelId?: string
|
||||
reasoningEffort?: string
|
||||
providerType?: string
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
}) => Promise<HarnessAgent>
|
||||
createOpenClawAgent: (
|
||||
input: OpenClawAgentMutationInput,
|
||||
@@ -114,20 +119,37 @@ export function createAgentPageActions(input: AgentPageActionInput) {
|
||||
const handleHarnessCreate = async () => {
|
||||
if (!input.newName.trim()) return
|
||||
|
||||
const isHermes = input.createRuntime === 'hermes'
|
||||
// Hermes pulls every provider field from the user's selected entry
|
||||
// in the global LLM-providers list (managed under AI Settings). The
|
||||
// backend rejects creation if any required field is missing.
|
||||
const hermesProvider = isHermes
|
||||
? input.selectableHermesProviders.find(
|
||||
(option) => option.id === input.createHermesProviderId,
|
||||
)
|
||||
: undefined
|
||||
const effectiveModelId = isHermes
|
||||
? hermesProvider?.modelId
|
||||
: input.harnessModelId || undefined
|
||||
|
||||
input.setCreateError(null)
|
||||
try {
|
||||
const agent = await input.createHarnessAgent({
|
||||
name: input.newName.trim(),
|
||||
adapter: input.createRuntime as HarnessAgentAdapter,
|
||||
modelId: input.harnessModelId || undefined,
|
||||
modelId: effectiveModelId,
|
||||
reasoningEffort: input.harnessReasoningEffort || undefined,
|
||||
providerType: hermesProvider?.type,
|
||||
apiKey: hermesProvider?.apiKey,
|
||||
baseUrl: hermesProvider?.baseUrl,
|
||||
})
|
||||
input.setCreateOpen(false)
|
||||
input.setNewName('')
|
||||
track(AGENT_CREATED_EVENT, {
|
||||
runtime: input.createRuntime,
|
||||
model_id: input.harnessModelId || undefined,
|
||||
model_id: effectiveModelId,
|
||||
reasoning_effort: input.harnessReasoningEffort || undefined,
|
||||
provider_type: hermesProvider?.type,
|
||||
})
|
||||
input.navigate(`/agents/${agent.id}`)
|
||||
} catch (err) {
|
||||
@@ -140,6 +162,7 @@ export function createAgentPageActions(input: AgentPageActionInput) {
|
||||
openclaw: handleOpenClawCreate,
|
||||
claude: handleHarnessCreate,
|
||||
codex: handleHarnessCreate,
|
||||
hermes: handleHarnessCreate,
|
||||
}
|
||||
void createByRuntime[input.createRuntime]()
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import type {
|
||||
HarnessAdapterDescriptor,
|
||||
HarnessAgentAdapter,
|
||||
} from './agent-harness-types'
|
||||
import type { CreateAgentRuntime } from './agents-page-types'
|
||||
import type { CreateAgentRuntime, ProviderOption } from './agents-page-types'
|
||||
import { toProviderOptions } from './agents-page-utils'
|
||||
import { getHermesSupportedProviders } from './hermes-supported-providers'
|
||||
import {
|
||||
buildOpenClawCliProviderOptions,
|
||||
findOpenClawCliProviderById,
|
||||
@@ -171,3 +172,60 @@ export function useOpenClawProviderSelection(input: {
|
||||
cliAuthError,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of useOpenClawProviderSelection but for Hermes. Hermes only
|
||||
* needs the create-dialog flow (no setup dialog, no CLI providers), so
|
||||
* this hook is much smaller — it just filters the global provider list
|
||||
* to ones Hermes can drive and seeds the selected id when the dialog
|
||||
* opens.
|
||||
*/
|
||||
export function useHermesProviderSelection(input: {
|
||||
providers: LlmProviderConfig[]
|
||||
defaultProviderId: string
|
||||
createOpen: boolean
|
||||
createRuntime: CreateAgentRuntime
|
||||
createHermesProviderId: string
|
||||
setCreateHermesProviderId: Dispatch<SetStateAction<string>>
|
||||
}) {
|
||||
const {
|
||||
providers,
|
||||
defaultProviderId,
|
||||
createOpen,
|
||||
createRuntime,
|
||||
createHermesProviderId,
|
||||
setCreateHermesProviderId,
|
||||
} = input
|
||||
|
||||
const selectableHermesProviders = useMemo<ProviderOption[]>(
|
||||
() =>
|
||||
getHermesSupportedProviders(providers).map((provider) => ({
|
||||
id: provider.id,
|
||||
type: provider.type,
|
||||
name: provider.name,
|
||||
modelId: provider.modelId,
|
||||
baseUrl: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
})),
|
||||
[providers],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectableHermesProviders.length === 0) return
|
||||
if (!createOpen || createRuntime !== 'hermes') return
|
||||
if (createHermesProviderId) return
|
||||
const fallbackId =
|
||||
selectableHermesProviders.find((p) => p.id === defaultProviderId)?.id ??
|
||||
selectableHermesProviders[0].id
|
||||
setCreateHermesProviderId(fallbackId)
|
||||
}, [
|
||||
createHermesProviderId,
|
||||
createOpen,
|
||||
createRuntime,
|
||||
defaultProviderId,
|
||||
selectableHermesProviders,
|
||||
setCreateHermesProviderId,
|
||||
])
|
||||
|
||||
return { selectableHermesProviders }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES,
|
||||
type HermesSupportedBrowserosProviderType,
|
||||
} from '@browseros/shared/constants/hermes'
|
||||
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
export function isHermesSupportedProviderType(
|
||||
providerType: ProviderType,
|
||||
): providerType is HermesSupportedBrowserosProviderType {
|
||||
return (
|
||||
HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES as readonly ProviderType[]
|
||||
).includes(providerType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the user's global LLM providers down to ones Hermes can use.
|
||||
* A provider qualifies when its type is in the Hermes-supported set
|
||||
* AND it has an API key wired up. CLI-style providers (chatgpt-pro,
|
||||
* github-copilot, qwen-code) and other unsupported types (browseros,
|
||||
* ollama, lmstudio, bedrock, azure, google, moonshot) are filtered
|
||||
* out — Hermes can't drive them today.
|
||||
*/
|
||||
export function getHermesSupportedProviders(
|
||||
providers: LlmProviderConfig[],
|
||||
): LlmProviderConfig[] {
|
||||
return providers.filter(
|
||||
(provider) =>
|
||||
!!provider.apiKey && isHermesSupportedProviderType(provider.type),
|
||||
)
|
||||
}
|
||||
@@ -25,12 +25,18 @@ interface HarnessAgentsResponse {
|
||||
|
||||
export type { AgentHarnessStreamEvent }
|
||||
|
||||
const AGENT_QUERY_KEYS = {
|
||||
export const AGENT_QUERY_KEYS = {
|
||||
adapters: 'agent-harness-adapters',
|
||||
agents: 'agent-harness-agents',
|
||||
/** Outputs-rail data for one agent — `[agentOutputs, baseUrl, agentId]`. */
|
||||
agentOutputs: 'agent-harness-agent-outputs',
|
||||
/** Per-turn artifact-card files — `[agentTurnFiles, baseUrl, agentId, turnId]`. */
|
||||
agentTurnFiles: 'agent-harness-agent-turn-files',
|
||||
/** Single-file preview payload — `[filePreview, baseUrl, fileId]`. */
|
||||
filePreview: 'agent-harness-file-preview',
|
||||
} as const
|
||||
|
||||
async function agentsFetch<T>(
|
||||
export async function agentsFetch<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
|
||||
@@ -85,7 +85,8 @@ export const SidebarLayout: FC = () => {
|
||||
|
||||
return (
|
||||
<RpcClientProvider>
|
||||
<div className="relative min-h-screen bg-background">
|
||||
{/* pl-14 offsets all content by the collapsed sidebar width (w-14 = 56px) so it never sits under the rail */}
|
||||
<div className="relative min-h-screen bg-background pl-14">
|
||||
{/* Sidebar - fixed overlay */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: hover interactions needed */}
|
||||
<div
|
||||
@@ -96,7 +97,6 @@ export const SidebarLayout: FC = () => {
|
||||
<AppSidebar expanded={sidebarOpen} onOpenShortcuts={openShortcuts} />
|
||||
</div>
|
||||
|
||||
{/* Main content - full width, centered */}
|
||||
{location.pathname === '/home/chat' ? (
|
||||
<main className="relative h-dvh overflow-hidden">
|
||||
<Outlet />
|
||||
|
||||
@@ -108,6 +108,7 @@ function formatAdapterName(adapter: HarnessAgentAdapter): string {
|
||||
if (adapter === 'claude') return 'Claude Code'
|
||||
if (adapter === 'codex') return 'Codex'
|
||||
if (adapter === 'openclaw') return 'OpenClaw'
|
||||
if (adapter === 'hermes') return 'Hermes'
|
||||
return adapter
|
||||
}
|
||||
|
||||
|
||||
@@ -42,11 +42,34 @@ export interface UserAttachmentPreview {
|
||||
dataUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Files attributed to this turn by the harness's per-turn workspace
|
||||
* diff. Populated either via the live `produced_files` SSE event or
|
||||
* (on resume) the `useAgentTurnFiles` fallback. Mirrors the wire
|
||||
* shape from `agent-harness-types.HarnessProducedFile` minus the
|
||||
* stream-only fields the inline card doesn't need.
|
||||
*/
|
||||
export interface ConversationTurnFile {
|
||||
id: string
|
||||
path: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
export interface AgentConversationTurn {
|
||||
id: string
|
||||
/**
|
||||
* Server-issued turn id, set as soon as the response headers arrive
|
||||
* (`X-Turn-Id`) for fresh sends, or from the active-turn payload on
|
||||
* resume. Required for the historic-files fallback fetch; absent on
|
||||
* the brief optimistic window before the first header.
|
||||
*/
|
||||
turnId?: string | null
|
||||
userText: string
|
||||
userAttachments?: UserAttachmentPreview[]
|
||||
parts: AssistantPart[]
|
||||
/** Files produced during this turn (openclaw only in v1). */
|
||||
producedFiles?: ConversationTurnFile[]
|
||||
done: boolean
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Pure helpers used by the artifact card and the Outputs rail.
|
||||
* Display formatting only — no React, no fetch, no DOM. Anything
|
||||
* stateful belongs in `./useAgentOutputs` or `./useFilePreview`.
|
||||
*/
|
||||
|
||||
import { buildAgentApiUrl } from '@/entrypoints/app/agents/agent-api-url'
|
||||
|
||||
/**
|
||||
* Coarse classification of a file's intended preview / icon path.
|
||||
* Mirrors the server-side `FilePreviewKind` minus `missing` — the
|
||||
* client only ever computes a kind for a row it already has.
|
||||
*/
|
||||
export type FileKind = 'text' | 'image' | 'pdf' | 'binary'
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'txt',
|
||||
'md',
|
||||
'markdown',
|
||||
'json',
|
||||
'jsonl',
|
||||
'csv',
|
||||
'tsv',
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml',
|
||||
'toml',
|
||||
'ini',
|
||||
'log',
|
||||
'html',
|
||||
'htm',
|
||||
'css',
|
||||
'js',
|
||||
'mjs',
|
||||
'cjs',
|
||||
'ts',
|
||||
'tsx',
|
||||
'jsx',
|
||||
'py',
|
||||
'rb',
|
||||
'go',
|
||||
'rs',
|
||||
'java',
|
||||
'kt',
|
||||
'swift',
|
||||
'c',
|
||||
'h',
|
||||
'cpp',
|
||||
'hpp',
|
||||
'sh',
|
||||
'zsh',
|
||||
'bash',
|
||||
'sql',
|
||||
'svg',
|
||||
])
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'webp',
|
||||
'bmp',
|
||||
'ico',
|
||||
'heic',
|
||||
'heif',
|
||||
])
|
||||
|
||||
/** Best-effort kind based on extension only. Server's preview API
|
||||
* is the source of truth for actual rendering — this is just for
|
||||
* picking an icon / sort hint without a network round-trip. */
|
||||
export function inferFileKind(path: string): FileKind {
|
||||
const ext = extensionOf(path).toLowerCase()
|
||||
if (ext === 'pdf') return 'pdf'
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return 'image'
|
||||
if (TEXT_EXTENSIONS.has(ext)) return 'text'
|
||||
return 'binary'
|
||||
}
|
||||
|
||||
/** Plain extension without the leading dot. Empty string when none. */
|
||||
export function extensionOf(path: string): string {
|
||||
const dot = path.lastIndexOf('.')
|
||||
if (dot === -1) return ''
|
||||
const slash = path.lastIndexOf('/')
|
||||
if (dot < slash) return ''
|
||||
return path.slice(dot + 1)
|
||||
}
|
||||
|
||||
/** File name (final path segment), no directory prefix. */
|
||||
export function basenameOf(path: string): string {
|
||||
const slash = path.lastIndexOf('/')
|
||||
return slash === -1 ? path : path.slice(slash + 1)
|
||||
}
|
||||
|
||||
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const
|
||||
|
||||
/** "2.4 MB" / "340 KB" / "78 B" — for the artifact card's right-side
|
||||
* metadata. Not localised; the rail uses one space + the unit. */
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '—'
|
||||
if (bytes < 1024) return `${bytes} ${SIZE_UNITS[0]}`
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
|
||||
value /= 1024
|
||||
unit += 1
|
||||
}
|
||||
// 1-digit precision below 10, integer above — feels less noisy.
|
||||
const formatted = value < 10 ? value.toFixed(1) : Math.round(value).toString()
|
||||
return `${formatted} ${SIZE_UNITS[unit]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-file download URL using the same agent-api root the
|
||||
* rest of the harness hits. Returned URL is already absolute.
|
||||
*/
|
||||
export function buildFileDownloadUrl(baseUrl: string, fileId: string): string {
|
||||
return buildAgentApiUrl(
|
||||
baseUrl,
|
||||
`/files/${encodeURIComponent(fileId)}/download`,
|
||||
)
|
||||
}
|
||||
32
packages/browseros-agent/apps/agent/lib/agent-files/index.ts
Normal file
32
packages/browseros-agent/apps/agent/lib/agent-files/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export {
|
||||
basenameOf,
|
||||
buildFileDownloadUrl,
|
||||
extensionOf,
|
||||
type FileKind,
|
||||
formatFileSize,
|
||||
inferFileKind,
|
||||
} from './file-helpers'
|
||||
export type {
|
||||
BinaryFilePreview,
|
||||
FilePreview,
|
||||
FilePreviewKind,
|
||||
ImageFilePreview,
|
||||
MissingFilePreview,
|
||||
PdfFilePreview,
|
||||
ProducedFile,
|
||||
ProducedFilesRailGroup,
|
||||
TextFilePreview,
|
||||
} from './types'
|
||||
export {
|
||||
useAgentOutputs,
|
||||
useAgentTurnFiles,
|
||||
useInvalidateAgentOutputs,
|
||||
useRefreshAgentOutputs,
|
||||
} from './useAgentOutputs'
|
||||
export { useFilePreview } from './useFilePreview'
|
||||
75
packages/browseros-agent/apps/agent/lib/agent-files/types.ts
Normal file
75
packages/browseros-agent/apps/agent/lib/agent-files/types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Wire types shared by the inline artifact card and the per-agent
|
||||
* Outputs rail. These mirror `ProducedFileEntry` /
|
||||
* `ProducedFilesRailGroup` on the server and the `FilePreview`
|
||||
* discriminated union from `apps/server/src/api/services/openclaw/file-preview.ts`.
|
||||
*
|
||||
* The schema mirror is deliberate (vs sharing a workspace package)
|
||||
* because the server keeps the on-disk row shape — `agentDefinitionId`,
|
||||
* `sessionKey` — out of the wire payload. Dropping those columns at the
|
||||
* type boundary keeps the client honest about what it can refer to.
|
||||
*/
|
||||
|
||||
export interface ProducedFile {
|
||||
id: string
|
||||
/** Workspace-relative POSIX path. */
|
||||
path: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
/** Server clock when the file was first attributed to its turn. */
|
||||
createdAt: number
|
||||
detectedBy: 'diff' | 'tool'
|
||||
}
|
||||
|
||||
export interface ProducedFilesRailGroup {
|
||||
turnId: string
|
||||
/** First non-blank line of the user prompt that initiated this turn. */
|
||||
turnPrompt: string
|
||||
createdAt: number
|
||||
files: ProducedFile[]
|
||||
}
|
||||
|
||||
export type FilePreviewKind = 'text' | 'image' | 'pdf' | 'binary' | 'missing'
|
||||
|
||||
interface BasePreview {
|
||||
kind: FilePreviewKind
|
||||
mimeType: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
export interface TextFilePreview extends BasePreview {
|
||||
kind: 'text'
|
||||
snippet: string
|
||||
/** True when the on-disk file is larger than the server's snippet cap. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export interface ImageFilePreview extends BasePreview {
|
||||
kind: 'image'
|
||||
/** Base64 data URL (incl. `data:` prefix). Suitable for `<img src>`. */
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
export interface PdfFilePreview extends BasePreview {
|
||||
kind: 'pdf'
|
||||
}
|
||||
|
||||
export interface BinaryFilePreview extends BasePreview {
|
||||
kind: 'binary'
|
||||
}
|
||||
|
||||
export interface MissingFilePreview {
|
||||
kind: 'missing'
|
||||
}
|
||||
|
||||
export type FilePreview =
|
||||
| TextFilePreview
|
||||
| ImageFilePreview
|
||||
| PdfFilePreview
|
||||
| BinaryFilePreview
|
||||
| MissingFilePreview
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* React Query hooks backing the per-agent Outputs rail and the
|
||||
* inline artifact card.
|
||||
*
|
||||
* Live updates: the consumer of `useAgentConversation` (see Phase 5)
|
||||
* is expected to call `useInvalidateAgentOutputs(agentId)` whenever
|
||||
* an assistant turn completes, so the rail picks up the new
|
||||
* `produced_files` rows the server attributed during that turn.
|
||||
* No SSE channel here — invalidation off the existing chat-stream
|
||||
* completion is enough for v1.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
AGENT_QUERY_KEYS,
|
||||
agentsFetch,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import type { ProducedFile, ProducedFilesRailGroup } from './types'
|
||||
|
||||
interface OutputsResponse {
|
||||
groups: ProducedFilesRailGroup[]
|
||||
}
|
||||
|
||||
interface TurnFilesResponse {
|
||||
files: ProducedFile[]
|
||||
}
|
||||
|
||||
export function useAgentOutputs(agentId: string, enabled = true) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<ProducedFilesRailGroup[], Error>({
|
||||
queryKey: [AGENT_QUERY_KEYS.agentOutputs, baseUrl, agentId],
|
||||
queryFn: async () => {
|
||||
const data = await agentsFetch<OutputsResponse>(
|
||||
baseUrl as string,
|
||||
`/${encodeURIComponent(agentId)}/files`,
|
||||
)
|
||||
return data.groups ?? []
|
||||
},
|
||||
enabled: Boolean(baseUrl) && !urlLoading && enabled && Boolean(agentId),
|
||||
})
|
||||
|
||||
return {
|
||||
groups: query.data ?? [],
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-turn fetch for the inline artifact card. Used both as the
|
||||
* fallback when an SSE `produced_files` event was missed, and to
|
||||
* rehydrate a turn the user scrolled back to.
|
||||
*/
|
||||
export function useAgentTurnFiles(
|
||||
agentId: string,
|
||||
turnId: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<ProducedFile[], Error>({
|
||||
queryKey: [AGENT_QUERY_KEYS.agentTurnFiles, baseUrl, agentId, turnId],
|
||||
queryFn: async () => {
|
||||
const data = await agentsFetch<TurnFilesResponse>(
|
||||
baseUrl as string,
|
||||
`/${encodeURIComponent(agentId)}/files/turn/${encodeURIComponent(
|
||||
turnId as string,
|
||||
)}`,
|
||||
)
|
||||
return data.files ?? []
|
||||
},
|
||||
enabled:
|
||||
Boolean(baseUrl) &&
|
||||
!urlLoading &&
|
||||
enabled &&
|
||||
Boolean(agentId) &&
|
||||
Boolean(turnId),
|
||||
})
|
||||
|
||||
return {
|
||||
files: query.data ?? [],
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a callable that invalidates outputs / turn-files queries
|
||||
* for one agent across any baseUrl. Call after an assistant turn
|
||||
* completes so the rail (and the inline file-card strip) pick up
|
||||
* the new attributed rows. Cheap when the queries aren't mounted
|
||||
* — react-query just marks the cached value stale.
|
||||
*
|
||||
* Implementation note: react-query's `invalidateQueries({ queryKey })`
|
||||
* does positional partial-match, so passing `undefined` as the
|
||||
* baseUrl placeholder does NOT match a cached `[…, baseUrl, …]`
|
||||
* key — the cache stayed stale. Use a predicate so we ignore the
|
||||
* baseUrl position entirely.
|
||||
*/
|
||||
export function useInvalidateAgentOutputs() {
|
||||
const queryClient = useQueryClient()
|
||||
return async (agentId: string, turnId?: string) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey
|
||||
return (
|
||||
Array.isArray(key) &&
|
||||
key[0] === AGENT_QUERY_KEYS.agentOutputs &&
|
||||
key[2] === agentId
|
||||
)
|
||||
},
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey
|
||||
if (
|
||||
!Array.isArray(key) ||
|
||||
key[0] !== AGENT_QUERY_KEYS.agentTurnFiles ||
|
||||
key[2] !== agentId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// When a turnId was supplied, scope to just that turn's
|
||||
// entry. Otherwise flush every cached turn for this agent.
|
||||
return turnId ? key[3] === turnId : true
|
||||
},
|
||||
}),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny mutation wrapper so the Outputs rail's "Refresh" button can
|
||||
* surface an `isPending` indicator while the new query is in flight.
|
||||
* No body — just triggers `refetch` on the rail's query for this
|
||||
* agent and resolves when it settles.
|
||||
*/
|
||||
export function useRefreshAgentOutputs(agentId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
const { baseUrl } = useAgentServerUrl()
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: [AGENT_QUERY_KEYS.agentOutputs, baseUrl, agentId],
|
||||
exact: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Single-file preview hook used by the inline artifact card and the
|
||||
* Outputs rail's preview Sheet. Always opt-in (`enabled`) — the
|
||||
* preview is fetched only when the user clicks a row, never
|
||||
* eagerly.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
AGENT_QUERY_KEYS,
|
||||
agentsFetch,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import type { FilePreview } from './types'
|
||||
|
||||
export function useFilePreview(fileId: string | null, enabled = true) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<FilePreview, Error>({
|
||||
queryKey: [AGENT_QUERY_KEYS.filePreview, baseUrl, fileId],
|
||||
queryFn: async () => {
|
||||
return agentsFetch<FilePreview>(
|
||||
baseUrl as string,
|
||||
`/files/${encodeURIComponent(fileId as string)}/preview`,
|
||||
)
|
||||
},
|
||||
enabled: Boolean(baseUrl) && !urlLoading && enabled && Boolean(fileId),
|
||||
// Previews are immutable for a given fileId — once loaded, never
|
||||
// refetch on focus / reconnect. They go stale only when the
|
||||
// underlying file is removed (rare in v1; no rename / delete).
|
||||
staleTime: Infinity,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
return {
|
||||
preview: query.data ?? null,
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
@@ -503,7 +503,7 @@ async function scenarioConfig(): Promise<void> {
|
||||
await tearDown(s)
|
||||
return
|
||||
}
|
||||
const newValue = target.options![0].value
|
||||
const newValue = target.options?.[0].value
|
||||
console.log(`[config] setting configId=${target.id} value=${newValue}`)
|
||||
try {
|
||||
// @ts-expect-error - input shape varies
|
||||
|
||||
@@ -14,7 +14,6 @@ import { stream } from 'hono/streaming'
|
||||
import { formatUserMessage } from '../../agent/format-message'
|
||||
import type { Browser } from '../../browser/browser'
|
||||
import { createAcpUIMessageStreamResponse } from '../../lib/agents/acp-ui-message-stream'
|
||||
import type { OpenclawGatewayAccessor } from '../../lib/agents/acpx-runtime'
|
||||
import type {
|
||||
ActiveTurnInfo,
|
||||
TurnFrame,
|
||||
@@ -35,15 +34,18 @@ import {
|
||||
type AgentDefinitionWithActivity,
|
||||
AgentHarnessService,
|
||||
type GatewayStatusSnapshot,
|
||||
HermesProviderConfigInvalidError,
|
||||
InvalidAgentUpdateError,
|
||||
MessageQueueFullError,
|
||||
type OpenClawProvisioner,
|
||||
OpenClawProvisionerUnavailableError,
|
||||
type ProducedFileEntry,
|
||||
type ProducedFilesRailGroup,
|
||||
type QueuedMessage,
|
||||
TurnAlreadyActiveError,
|
||||
UnknownAgentError,
|
||||
} from '../services/agents/agent-harness-service'
|
||||
import type { OpenClawGatewayChatClient } from '../services/openclaw/openclaw-gateway-chat-client'
|
||||
import type { FilePreview } from '../services/openclaw/file-preview'
|
||||
import type { Env } from '../types'
|
||||
import { resolveBrowserContextPageIds } from '../utils/resolve-browser-context-page-ids'
|
||||
|
||||
@@ -95,32 +97,44 @@ type AgentRouteService = {
|
||||
messageId: string
|
||||
}): Promise<boolean>
|
||||
listQueuedMessages(agentId: string): Promise<QueuedMessage[]>
|
||||
|
||||
// Files API — Phase 3 of TKT-762.
|
||||
listAgentFiles(
|
||||
agentId: string,
|
||||
options?: { limit?: number },
|
||||
): Promise<ProducedFilesRailGroup[]>
|
||||
listAgentFilesForTurn(
|
||||
agentId: string,
|
||||
turnId: string,
|
||||
): Promise<ProducedFileEntry[]>
|
||||
previewProducedFile(fileId: string): Promise<FilePreview | null>
|
||||
resolveProducedFileForDownload(fileId: string): Promise<{
|
||||
absolutePath: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
} | null>
|
||||
}
|
||||
|
||||
type AgentRouteDeps = {
|
||||
service?: AgentRouteService
|
||||
browser?: Pick<Browser, 'resolveTabIds'>
|
||||
browserosServerPort?: number
|
||||
/**
|
||||
* Required when an `openclaw` adapter agent is in use; harmless when
|
||||
* absent. Forwarded to the AcpxRuntime so it can spawn `openclaw acp`
|
||||
* inside the gateway container.
|
||||
*/
|
||||
openclawGateway?: OpenclawGatewayAccessor
|
||||
/**
|
||||
* Optional. Enables the image-attachment carve-out for OpenClaw
|
||||
* agents — image-bearing turns route through the gateway HTTP
|
||||
* `/v1/chat/completions` instead of the ACP bridge (which drops
|
||||
* image content blocks).
|
||||
*/
|
||||
openclawGatewayChat?: OpenClawGatewayChatClient
|
||||
/**
|
||||
* Required to dual-create/delete `openclaw` adapter agents on the
|
||||
* gateway side. Without this, openclaw create requests fail with 503.
|
||||
*/
|
||||
openclawProvisioner?: OpenClawProvisioner
|
||||
/** Optional override; defaults to a fresh in-memory checker. */
|
||||
adapterHealth?: AdapterHealthChecker
|
||||
/**
|
||||
* Optional listener attached to the constructed harness. Receives
|
||||
* turn lifecycle events for every running agent. Wired by the server
|
||||
* to feed OpenClaw's ClawSession dashboard from the same stream the
|
||||
* chat panel sees, so no second WS observer is needed.
|
||||
*/
|
||||
onTurnLifecycle?: import('../services/agents/agent-harness-service').TurnLifecycleListener
|
||||
}
|
||||
|
||||
type SidepanelAgentChatRequest = {
|
||||
@@ -138,268 +152,381 @@ export function createAgentRoutes(deps: AgentRouteDeps = {}) {
|
||||
deps.service ??
|
||||
new AgentHarnessService({
|
||||
browserosServerPort: deps.browserosServerPort,
|
||||
openclawGateway: deps.openclawGateway,
|
||||
openclawGatewayChat: deps.openclawGatewayChat,
|
||||
openclawProvisioner: deps.openclawProvisioner,
|
||||
})
|
||||
if (deps.onTurnLifecycle && service instanceof AgentHarnessService) {
|
||||
service.onTurnLifecycle(deps.onTurnLifecycle)
|
||||
}
|
||||
// One checker per route mount. Cached probes refresh every 5min;
|
||||
// tests can swap in an alternate via deps if needed.
|
||||
const adapterHealth = deps.adapterHealth ?? new AdapterHealthChecker()
|
||||
|
||||
return new Hono<Env>()
|
||||
.get('/adapters', async (c) => {
|
||||
const adapters = await Promise.all(
|
||||
AGENT_ADAPTER_CATALOG.map(async (descriptor) => ({
|
||||
...descriptor,
|
||||
health: await adapterHealth.getHealth(descriptor.id),
|
||||
})),
|
||||
)
|
||||
return c.json({ adapters })
|
||||
})
|
||||
.get('/', async (c) => {
|
||||
// Single round-trip the agents page consumes: enriched agents
|
||||
// (status + lastUsedAt) plus the gateway lifecycle snapshot the
|
||||
// GatewayStatusBar / GatewayStateCards / ControlPlaneAlert used
|
||||
// to fetch from `/claw/status`. Lets the page poll one endpoint.
|
||||
const [agents, gateway] = await Promise.all([
|
||||
service.listAgentsWithActivity(),
|
||||
service.getGatewayStatus(),
|
||||
])
|
||||
return c.json({ agents, gateway })
|
||||
})
|
||||
.post('/', async (c) => {
|
||||
const parsed = await parseCreateAgentBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
return c.json({ agent: await service.createAgent(parsed) })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/sidepanel/chat', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const parsed = await parseSidepanelAgentChatBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
|
||||
try {
|
||||
const agent = await service.getAgent(agentId)
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
|
||||
let browserContext = parsed.browserContext
|
||||
if (deps.browser) {
|
||||
browserContext = await resolveBrowserContextPageIds(
|
||||
deps.browser,
|
||||
browserContext,
|
||||
)
|
||||
}
|
||||
|
||||
const userContent = formatUserMessage(
|
||||
parsed.message,
|
||||
browserContext,
|
||||
parsed.selectedText,
|
||||
parsed.selectedTextSource,
|
||||
return (
|
||||
new Hono<Env>()
|
||||
.get('/adapters', async (c) => {
|
||||
const adapters = await Promise.all(
|
||||
AGENT_ADAPTER_CATALOG.map(async (descriptor) => ({
|
||||
...descriptor,
|
||||
health: await adapterHealth.getHealth(descriptor.id),
|
||||
})),
|
||||
)
|
||||
const message = parsed.userSystemPrompt?.trim()
|
||||
? `${parsed.userSystemPrompt.trim()}\n\n${userContent}`
|
||||
: userContent
|
||||
return c.json({ adapters })
|
||||
})
|
||||
.get('/', async (c) => {
|
||||
// Single round-trip the agents page consumes: enriched agents
|
||||
// (status + lastUsedAt) plus the gateway lifecycle snapshot the
|
||||
// GatewayStatusBar / GatewayStateCards / ControlPlaneAlert used
|
||||
// to fetch from `/claw/status`. Lets the page poll one endpoint.
|
||||
const [agents, gateway] = await Promise.all([
|
||||
service.listAgentsWithActivity(),
|
||||
service.getGatewayStatus(),
|
||||
])
|
||||
return c.json({ agents, gateway })
|
||||
})
|
||||
.post('/', async (c) => {
|
||||
const parsed = await parseCreateAgentBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
return c.json({ agent: await service.createAgent(parsed) })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/sidepanel/chat', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const parsed = await parseSidepanelAgentChatBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
|
||||
try {
|
||||
const agent = await service.getAgent(agentId)
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
|
||||
let browserContext = parsed.browserContext
|
||||
if (deps.browser) {
|
||||
browserContext = await resolveBrowserContextPageIds(
|
||||
deps.browser,
|
||||
browserContext,
|
||||
)
|
||||
}
|
||||
|
||||
const userContent = formatUserMessage(
|
||||
parsed.message,
|
||||
browserContext,
|
||||
parsed.selectedText,
|
||||
parsed.selectedTextSource,
|
||||
)
|
||||
const message = parsed.userSystemPrompt?.trim()
|
||||
? `${parsed.userSystemPrompt.trim()}\n\n${userContent}`
|
||||
: userContent
|
||||
|
||||
let started: { turnId: string; frames: ReadableStream<TurnFrame> }
|
||||
try {
|
||||
started = await service.startTurn({
|
||||
agentId: agent.id,
|
||||
message,
|
||||
cwd: parsed.userWorkingDir,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof TurnAlreadyActiveError) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Turn already active',
|
||||
turnId: err.turnId,
|
||||
attachUrl: `/agents/${agent.id}/chat/stream?turnId=${err.turnId}`,
|
||||
},
|
||||
409,
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
let didRequestCancel = false
|
||||
const cancelStartedTurn = () => {
|
||||
if (didRequestCancel) return
|
||||
didRequestCancel = true
|
||||
service.cancelTurn({
|
||||
agentId: agent.id,
|
||||
turnId: started.turnId,
|
||||
reason: 'sidepanel stream cancelled',
|
||||
})
|
||||
}
|
||||
if (c.req.raw.signal.aborted) {
|
||||
cancelStartedTurn()
|
||||
} else {
|
||||
c.req.raw.signal.addEventListener('abort', cancelStartedTurn, {
|
||||
once: true,
|
||||
})
|
||||
}
|
||||
|
||||
const events = turnFramesToAgentEvents(started.frames, {
|
||||
onCancel: cancelStartedTurn,
|
||||
})
|
||||
|
||||
return createAcpUIMessageStreamResponse(events, {
|
||||
headers: {
|
||||
'X-Session-Id': 'main',
|
||||
'X-Turn-Id': started.turnId,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId', async (c) => {
|
||||
try {
|
||||
const agent = await service.getAgent(c.req.param('agentId'))
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
return c.json({ agent })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.delete('/:agentId', async (c) => {
|
||||
try {
|
||||
return c.json({
|
||||
success: await service.deleteAgent(c.req.param('agentId')),
|
||||
})
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.patch('/:agentId', async (c) => {
|
||||
const parsed = await parseAgentPatchBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
const agent = await service.updateAgent(
|
||||
c.req.param('agentId'),
|
||||
parsed.patch,
|
||||
)
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
return c.json({ agent })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId/sessions/main/history', async (c) => {
|
||||
try {
|
||||
return c.json(await service.getHistory(c.req.param('agentId')))
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/chat', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const parsed = await parseChatBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
|
||||
let started: { turnId: string; frames: ReadableStream<TurnFrame> }
|
||||
try {
|
||||
started = await service.startTurn({
|
||||
agentId: agent.id,
|
||||
message,
|
||||
cwd: parsed.userWorkingDir,
|
||||
agentId,
|
||||
message: parsed.message,
|
||||
attachments: parsed.attachments,
|
||||
cwd: parsed.cwd,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof TurnAlreadyActiveError) {
|
||||
// Caller can attach via GET /chat/stream?turnId=… instead.
|
||||
return c.json(
|
||||
{
|
||||
error: 'Turn already active',
|
||||
turnId: err.turnId,
|
||||
attachUrl: `/agents/${agent.id}/chat/stream?turnId=${err.turnId}`,
|
||||
attachUrl: `/agents/${agentId}/chat/stream?turnId=${err.turnId}`,
|
||||
},
|
||||
409,
|
||||
)
|
||||
}
|
||||
throw err
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
|
||||
let didRequestCancel = false
|
||||
const cancelStartedTurn = () => {
|
||||
if (didRequestCancel) return
|
||||
didRequestCancel = true
|
||||
service.cancelTurn({
|
||||
agentId: agent.id,
|
||||
turnId: started.turnId,
|
||||
reason: 'sidepanel stream cancelled',
|
||||
})
|
||||
}
|
||||
if (c.req.raw.signal.aborted) {
|
||||
cancelStartedTurn()
|
||||
} else {
|
||||
c.req.raw.signal.addEventListener('abort', cancelStartedTurn, {
|
||||
once: true,
|
||||
})
|
||||
}
|
||||
|
||||
const events = turnFramesToAgentEvents(started.frames, {
|
||||
onCancel: cancelStartedTurn,
|
||||
return streamTurnFrames(c, started.frames, {
|
||||
turnId: started.turnId,
|
||||
})
|
||||
|
||||
return createAcpUIMessageStreamResponse(events, {
|
||||
headers: {
|
||||
'X-Session-Id': 'main',
|
||||
'X-Turn-Id': started.turnId,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId', async (c) => {
|
||||
try {
|
||||
const agent = await service.getAgent(c.req.param('agentId'))
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
return c.json({ agent })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.delete('/:agentId', async (c) => {
|
||||
try {
|
||||
return c.json({
|
||||
success: await service.deleteAgent(c.req.param('agentId')),
|
||||
})
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.patch('/:agentId', async (c) => {
|
||||
const parsed = await parseAgentPatchBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
const agent = await service.updateAgent(
|
||||
c.req.param('agentId'),
|
||||
parsed.patch,
|
||||
)
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
return c.json({ agent })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId/sessions/main/history', async (c) => {
|
||||
try {
|
||||
return c.json(await service.getHistory(c.req.param('agentId')))
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/chat', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const parsed = await parseChatBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
|
||||
let started: { turnId: string; frames: ReadableStream<TurnFrame> }
|
||||
try {
|
||||
started = await service.startTurn({
|
||||
agentId,
|
||||
message: parsed.message,
|
||||
attachments: parsed.attachments,
|
||||
cwd: parsed.cwd,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof TurnAlreadyActiveError) {
|
||||
// Caller can attach via GET /chat/stream?turnId=… instead.
|
||||
return c.json(
|
||||
{
|
||||
error: 'Turn already active',
|
||||
turnId: err.turnId,
|
||||
attachUrl: `/agents/${agentId}/chat/stream?turnId=${err.turnId}`,
|
||||
},
|
||||
409,
|
||||
)
|
||||
}
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
|
||||
return streamTurnFrames(c, started.frames, {
|
||||
turnId: started.turnId,
|
||||
})
|
||||
})
|
||||
.get('/:agentId/chat/active', (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const info = service.getActiveTurn(agentId, 'main')
|
||||
return c.json({ active: info })
|
||||
})
|
||||
.get('/:agentId/chat/stream', (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const url = new URL(c.req.url)
|
||||
const queryTurnId = url.searchParams.get('turnId')?.trim() || undefined
|
||||
const turnId =
|
||||
queryTurnId ?? service.getActiveTurn(agentId, 'main')?.turnId
|
||||
if (!turnId) {
|
||||
return c.json({ error: 'No active turn for this agent' }, 404)
|
||||
}
|
||||
const lastEventId =
|
||||
c.req.header('Last-Event-ID') ??
|
||||
url.searchParams.get('lastSeq') ??
|
||||
undefined
|
||||
const lastSeq = parseLastSeq(lastEventId)
|
||||
const frames = service.attachTurn({ turnId, lastSeq })
|
||||
if (!frames) {
|
||||
return c.json({ error: 'Unknown turn' }, 404)
|
||||
}
|
||||
return streamTurnFrames(c, frames, { turnId })
|
||||
})
|
||||
.post('/:agentId/chat/cancel', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const body = await readJsonBody(c)
|
||||
const turnId =
|
||||
'value' in body && typeof body.value.turnId === 'string'
|
||||
? body.value.turnId.trim() || undefined
|
||||
: undefined
|
||||
const reason =
|
||||
'value' in body && typeof body.value.reason === 'string'
|
||||
? body.value.reason
|
||||
: undefined
|
||||
const cancelled = service.cancelTurn({ agentId, turnId, reason })
|
||||
return c.json({ cancelled })
|
||||
})
|
||||
.get('/:agentId/queue', async (c) => {
|
||||
try {
|
||||
const queue = await service.listQueuedMessages(c.req.param('agentId'))
|
||||
return c.json({ queue })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/queue', async (c) => {
|
||||
const parsed = await parseEnqueueBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
const queued = await service.enqueueMessage({
|
||||
agentId: c.req.param('agentId'),
|
||||
message: parsed.message,
|
||||
attachments: parsed.attachments,
|
||||
})
|
||||
return c.json({ queued })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.delete('/:agentId/queue/:messageId', async (c) => {
|
||||
try {
|
||||
const removed = await service.removeQueuedMessage({
|
||||
agentId: c.req.param('agentId'),
|
||||
messageId: c.req.param('messageId'),
|
||||
})
|
||||
if (!removed) return c.json({ error: 'Queued message not found' }, 404)
|
||||
return c.json({ removed })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId/chat/active', (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const info = service.getActiveTurn(agentId, 'main')
|
||||
return c.json({ active: info })
|
||||
})
|
||||
.get('/:agentId/chat/stream', (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const url = new URL(c.req.url)
|
||||
const queryTurnId = url.searchParams.get('turnId')?.trim() || undefined
|
||||
const turnId =
|
||||
queryTurnId ?? service.getActiveTurn(agentId, 'main')?.turnId
|
||||
if (!turnId) {
|
||||
return c.json({ error: 'No active turn for this agent' }, 404)
|
||||
}
|
||||
const lastEventId =
|
||||
c.req.header('Last-Event-ID') ??
|
||||
url.searchParams.get('lastSeq') ??
|
||||
undefined
|
||||
const lastSeq = parseLastSeq(lastEventId)
|
||||
const frames = service.attachTurn({ turnId, lastSeq })
|
||||
if (!frames) {
|
||||
return c.json({ error: 'Unknown turn' }, 404)
|
||||
}
|
||||
return streamTurnFrames(c, frames, { turnId })
|
||||
})
|
||||
.post('/:agentId/chat/cancel', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const body = await readJsonBody(c)
|
||||
const turnId =
|
||||
'value' in body && typeof body.value.turnId === 'string'
|
||||
? body.value.turnId.trim() || undefined
|
||||
: undefined
|
||||
const reason =
|
||||
'value' in body && typeof body.value.reason === 'string'
|
||||
? body.value.reason
|
||||
: undefined
|
||||
const cancelled = service.cancelTurn({ agentId, turnId, reason })
|
||||
return c.json({ cancelled })
|
||||
})
|
||||
.get('/:agentId/queue', async (c) => {
|
||||
try {
|
||||
const queue = await service.listQueuedMessages(c.req.param('agentId'))
|
||||
return c.json({ queue })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/queue', async (c) => {
|
||||
const parsed = await parseEnqueueBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
const queued = await service.enqueueMessage({
|
||||
agentId: c.req.param('agentId'),
|
||||
message: parsed.message,
|
||||
attachments: parsed.attachments,
|
||||
})
|
||||
return c.json({ queued })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.delete('/:agentId/queue/:messageId', async (c) => {
|
||||
try {
|
||||
const removed = await service.removeQueuedMessage({
|
||||
agentId: c.req.param('agentId'),
|
||||
messageId: c.req.param('messageId'),
|
||||
})
|
||||
if (!removed)
|
||||
return c.json({ error: 'Queued message not found' }, 404)
|
||||
return c.json({ removed })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Files (TKT-762) ────────────────────────────────────────────
|
||||
//
|
||||
// V1 surfaces files OpenClaw agents produce inside their workspace
|
||||
// dir (`~/.browseros/vm/openclaw/.openclaw/workspace[-<name>]/`)
|
||||
// as outputs, attributed back to the chat turn that produced them
|
||||
// by the per-turn workspace diff in
|
||||
// `agent-harness-service.runDetachedTurn`. Adapter-gated to
|
||||
// openclaw on the service side; for claude / codex these endpoints
|
||||
// simply return empty lists.
|
||||
//
|
||||
// The file-id-scoped endpoints (`/files/:fileId/{preview,download}`)
|
||||
// accept an opaque `fileId` and resolve the on-disk path
|
||||
// server-side, so the client never sees a raw path and traversal
|
||||
// is impossible by construction.
|
||||
|
||||
.get('/:agentId/files', async (c) => {
|
||||
try {
|
||||
const groups = await service.listAgentFiles(
|
||||
c.req.param('agentId'),
|
||||
parseAgentFilesLimit(c.req.query('limit')),
|
||||
)
|
||||
return c.json({ groups })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId/files/turn/:turnId', async (c) => {
|
||||
try {
|
||||
const files = await service.listAgentFilesForTurn(
|
||||
c.req.param('agentId'),
|
||||
c.req.param('turnId'),
|
||||
)
|
||||
return c.json({ files })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/files/:fileId/preview', async (c) => {
|
||||
try {
|
||||
const preview = await service.previewProducedFile(
|
||||
c.req.param('fileId'),
|
||||
)
|
||||
if (!preview || preview.kind === 'missing') {
|
||||
return c.json({ error: 'File not found' }, 404)
|
||||
}
|
||||
return c.json(preview)
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/files/:fileId/download', async (c) => {
|
||||
try {
|
||||
const resolved = await service.resolveProducedFileForDownload(
|
||||
c.req.param('fileId'),
|
||||
)
|
||||
if (!resolved) return c.json({ error: 'File not found' }, 404)
|
||||
|
||||
// Stream raw bytes via Bun's lazy file handle. Sets
|
||||
// Content-Disposition so browsers save instead of preview.
|
||||
const file = Bun.file(resolved.absolutePath)
|
||||
return new Response(file.stream(), {
|
||||
headers: {
|
||||
'Content-Type': resolved.mimeType,
|
||||
'Content-Length': String(resolved.size),
|
||||
'Content-Disposition': `attachment; ${encodeRfc6266Filename(resolved.fileName)}`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Hard cap on `?limit=` for /agents/:id/files — guards against
|
||||
* a caller-supplied huge value forcing a per-agent table scan. */
|
||||
const MAX_FILES_LIMIT = 500
|
||||
|
||||
/**
|
||||
* Parse + clamp the `limit` query for /agents/:id/files. Returns
|
||||
* `undefined` when the param is absent or unparseable so the
|
||||
* service falls back to its own default.
|
||||
*/
|
||||
function parseAgentFilesLimit(
|
||||
raw: string | undefined,
|
||||
): { limit: number } | undefined {
|
||||
if (!raw) return undefined
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(parsed)) return undefined
|
||||
return { limit: Math.min(Math.max(1, parsed), MAX_FILES_LIMIT) }
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 6266 / RFC 5987 filename attributes for `Content-Disposition`.
|
||||
* Returns the `filename="..."` attribute (always) plus a
|
||||
* percent-encoded `filename*=UTF-8''…` attribute when the name
|
||||
* contains non-ASCII characters, so browsers download with the
|
||||
* original name even on stricter HTTP clients.
|
||||
*/
|
||||
function encodeRfc6266Filename(filename: string): string {
|
||||
// Strip CRLFs and quotes (header injection guard).
|
||||
const safe = filename.replace(/["\r\n]/g, '_')
|
||||
// Detect non-ASCII; emit the RFC 5987 fallback attribute when
|
||||
// present. `encodeURIComponent` is the standard browser-safe
|
||||
// percent-encoder for this purpose.
|
||||
const hasNonAscii = /[^ -~]/.test(safe)
|
||||
if (!hasNonAscii) return `filename="${safe}"`
|
||||
return `filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`
|
||||
}
|
||||
|
||||
function turnFramesToAgentEvents(
|
||||
@@ -540,11 +667,14 @@ async function parseCreateAgentBody(c: Context<Env>): Promise<
|
||||
? record.reasoningEffort.trim()
|
||||
: undefined
|
||||
|
||||
// OpenClaw agents resolve their model from the gateway-side provider
|
||||
// config rather than from the harness catalog. Skip catalog model
|
||||
// validation for that adapter; everything else still uses the catalog.
|
||||
// OpenClaw and Hermes resolve their model from per-agent provider
|
||||
// config (gateway / config.yaml) rather than from the harness catalog.
|
||||
// Skip catalog model validation for those adapters — both have an
|
||||
// empty `models: []` in the catalog by design — everything else still
|
||||
// uses the catalog for validation.
|
||||
if (
|
||||
record.adapter !== 'openclaw' &&
|
||||
record.adapter !== 'hermes' &&
|
||||
!isSupportedAgentModel(record.adapter, modelId)
|
||||
) {
|
||||
return { error: 'Invalid modelId' }
|
||||
@@ -781,6 +911,9 @@ function handleAgentRouteError(c: Context<Env>, err: unknown) {
|
||||
if (err instanceof InvalidAgentUpdateError) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
if (err instanceof HermesProviderConfigInvalidError) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
if (err instanceof MessageQueueFullError) {
|
||||
return c.json({ error: err.message }, 429)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
connectKlavisInBackground,
|
||||
type KlavisProxyRef,
|
||||
} from './services/klavis/strata-proxy'
|
||||
import { OpenClawGatewayChatClient } from './services/openclaw/openclaw-gateway-chat-client'
|
||||
import { convertOpenClawHistoryToAgentHistory } from './services/openclaw/history-mapper'
|
||||
import { getOpenClawService } from './services/openclaw/openclaw-service'
|
||||
import type { Env, HttpServerConfig } from './types'
|
||||
import { defaultCorsConfig } from './utils/cors'
|
||||
@@ -136,17 +136,6 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
createAgentRoutes({
|
||||
browserosServerPort: port,
|
||||
browser,
|
||||
openclawGateway: {
|
||||
getGatewayToken: () => getOpenClawService().getGatewayToken(),
|
||||
getContainerName: () => OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
getLimaHomeDir: () => getLimaHomeDir(),
|
||||
getLimactlPath: () => resolveBundledLimactl(resourcesDir),
|
||||
getVmName: () => VM_NAME,
|
||||
},
|
||||
openclawGatewayChat: new OpenClawGatewayChatClient(
|
||||
() => getOpenClawService().getPort(),
|
||||
async () => getOpenClawService().getGatewayToken(),
|
||||
),
|
||||
openclawProvisioner: {
|
||||
createAgent: (input) => getOpenClawService().createAgent(input),
|
||||
removeAgent: (agentId) => getOpenClawService().removeAgent(agentId),
|
||||
@@ -159,6 +148,23 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
}))
|
||||
},
|
||||
getStatus: () => getOpenClawService().getStatus(),
|
||||
getAgentHistory: async (agentId) => {
|
||||
// Aggregated across the agent's main + every sub-session
|
||||
// (cron / hook / channel) so autonomous turns surface in
|
||||
// the chat panel alongside user-initiated ones.
|
||||
const raw = await getOpenClawService().getSessionHistory(
|
||||
`agent:${agentId}:main`,
|
||||
)
|
||||
return convertOpenClawHistoryToAgentHistory(agentId, raw)
|
||||
},
|
||||
},
|
||||
onTurnLifecycle: (agent, event) => {
|
||||
if (agent.adapter !== 'openclaw') return
|
||||
getOpenClawService().recordAgentTurnEvent(
|
||||
agent.id,
|
||||
agent.sessionKey,
|
||||
event,
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4,10 +4,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import {
|
||||
AcpxRuntime,
|
||||
type OpenclawGatewayAccessor,
|
||||
} from '../../../lib/agents/acpx-runtime'
|
||||
import { AcpxRuntime } from '../../../lib/agents/acpx-runtime'
|
||||
import {
|
||||
type ActiveTurnInfo,
|
||||
type TurnFrame,
|
||||
@@ -24,6 +21,8 @@ import {
|
||||
type QueuedMessage,
|
||||
type QueuedMessageAttachment,
|
||||
} from '../../../lib/agents/message-queue'
|
||||
import { writeHermesPerAgentProvider } from '../hermes/hermes-paths'
|
||||
import { getHermesProviderMapping } from '../hermes/hermes-provider-map'
|
||||
|
||||
export {
|
||||
MessageQueueFullError,
|
||||
@@ -31,14 +30,26 @@ export {
|
||||
type QueuedMessageAttachment,
|
||||
} from '../../../lib/agents/message-queue'
|
||||
|
||||
import { basename } from 'node:path'
|
||||
import type {
|
||||
AgentHistoryPage,
|
||||
AgentRowSnapshot,
|
||||
AgentRuntime,
|
||||
AgentStreamEvent,
|
||||
} from '../../../lib/agents/types'
|
||||
import { getOpenClawDir } from '../../../lib/browseros-dir'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import type { OpenClawGatewayChatClient } from '../openclaw/openclaw-gateway-chat-client'
|
||||
import {
|
||||
buildFilePreview,
|
||||
detectMimeType,
|
||||
type FilePreview,
|
||||
} from '../openclaw/file-preview'
|
||||
import { getHostWorkspaceDir } from '../openclaw/openclaw-env'
|
||||
import {
|
||||
type FileSnapshot,
|
||||
type ProducedFileRow,
|
||||
ProducedFilesStore,
|
||||
} from '../openclaw/produced-files-store'
|
||||
|
||||
export type AgentLiveness = 'working' | 'idle' | 'asleep' | 'error'
|
||||
|
||||
@@ -120,6 +131,15 @@ export interface OpenClawProvisioner {
|
||||
* gateway is not configured at all).
|
||||
*/
|
||||
getStatus?(): Promise<GatewayStatusSnapshot | null>
|
||||
/**
|
||||
* Optional. When wired, the harness uses this for `getHistory` on
|
||||
* openclaw-adapter agents so the chat panel sees autonomous
|
||||
* (cron / hook / channel) turns alongside user-typed turns. Without
|
||||
* this, history reads come from AcpxRuntime's local session record
|
||||
* which only contains user-initiated turns — autonomous activity
|
||||
* fires correctly but stays invisible to the panel.
|
||||
*/
|
||||
getAgentHistory?(agentId: string): Promise<AgentHistoryPage>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,12 +172,47 @@ export interface GatewayStatusSnapshot {
|
||||
| null
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-turn event the harness emits to subscribers. Lets services that
|
||||
* want to track liveness for a specific adapter (e.g. OpenClaw's
|
||||
* ClawSession dashboard) react to the same stream the chat panel sees,
|
||||
* without each adapter spawning its own gateway-side observer.
|
||||
*/
|
||||
export type TurnLifecycleEvent =
|
||||
| { type: 'turn_started' }
|
||||
| { type: 'turn_event'; event: AgentStreamEvent }
|
||||
| { type: 'turn_ended'; error?: string }
|
||||
|
||||
export type TurnLifecycleListener = (
|
||||
agent: {
|
||||
id: string
|
||||
adapter: AgentDefinition['adapter']
|
||||
sessionKey: string
|
||||
},
|
||||
event: TurnLifecycleEvent,
|
||||
) => void
|
||||
|
||||
export class AgentHarnessService {
|
||||
private readonly agentStore: AgentStore
|
||||
private readonly runtime: AgentRuntime
|
||||
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
|
||||
* called `initializeDb`). Tests that exercise file attribution can
|
||||
* inject an explicit store via `deps.producedFilesStore`.
|
||||
*/
|
||||
private explicitProducedFilesStore: ProducedFilesStore | null = null
|
||||
private cachedProducedFilesStore: ProducedFilesStore | null = null
|
||||
private inFlightReconcile: Promise<void> | null = null
|
||||
// In-memory liveness tracker. Lost on server restart (acceptable —
|
||||
// `lastUsedAt` survives via the acpx session record's `lastUsedAt`,
|
||||
@@ -173,11 +228,11 @@ export class AgentHarnessService {
|
||||
agentStore?: AgentStore
|
||||
runtime?: AgentRuntime
|
||||
browserosServerPort?: number
|
||||
openclawGateway?: OpenclawGatewayAccessor
|
||||
openclawGatewayChat?: OpenClawGatewayChatClient
|
||||
browserosDir?: string
|
||||
openclawProvisioner?: OpenClawProvisioner
|
||||
turnRegistry?: TurnRegistry
|
||||
messageQueue?: FileMessageQueue
|
||||
producedFilesStore?: ProducedFilesStore
|
||||
} = {},
|
||||
) {
|
||||
this.agentStore = deps.agentStore ?? new DbAgentStore()
|
||||
@@ -185,12 +240,14 @@ export class AgentHarnessService {
|
||||
deps.runtime ??
|
||||
new AcpxRuntime({
|
||||
browserosServerPort: deps.browserosServerPort,
|
||||
openclawGateway: deps.openclawGateway,
|
||||
openclawGatewayChat: deps.openclawGatewayChat,
|
||||
})
|
||||
this.openclawProvisioner = deps.openclawProvisioner ?? null
|
||||
this.turnRegistry = deps.turnRegistry ?? new TurnRegistry()
|
||||
this.messageQueue = deps.messageQueue ?? new FileMessageQueue()
|
||||
this.browserosDir = deps.browserosDir
|
||||
if (deps.producedFilesStore) {
|
||||
this.explicitProducedFilesStore = deps.producedFilesStore
|
||||
}
|
||||
// Drain any agents whose queue file survived a restart. The check
|
||||
// for `getActiveFor` inside `maybeStartNextFromQueue` guards
|
||||
// against double-firing if the in-memory turn registry happens to
|
||||
@@ -314,6 +371,39 @@ export class AgentHarnessService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to turn lifecycle events for every running agent. Returns
|
||||
* an unsubscribe function. Listeners are best-effort: a throwing
|
||||
* listener does not break the turn.
|
||||
*/
|
||||
onTurnLifecycle(listener: TurnLifecycleListener): () => void {
|
||||
this.turnLifecycleListeners.add(listener)
|
||||
return () => this.turnLifecycleListeners.delete(listener)
|
||||
}
|
||||
|
||||
private emitTurnLifecycle(
|
||||
agent: AgentDefinition,
|
||||
event: TurnLifecycleEvent,
|
||||
): void {
|
||||
if (this.turnLifecycleListeners.size === 0) return
|
||||
const summary = {
|
||||
id: agent.id,
|
||||
adapter: agent.adapter,
|
||||
sessionKey: agent.sessionKey,
|
||||
}
|
||||
for (const listener of this.turnLifecycleListeners) {
|
||||
try {
|
||||
listener(summary, event)
|
||||
} catch (err) {
|
||||
logger.warn('Turn lifecycle listener threw', {
|
||||
agentId: agent.id,
|
||||
eventType: event.type,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark `agentId` as actively running a turn. */
|
||||
notifyTurnStarted(agentId: string): void {
|
||||
this.activity.set(agentId, { status: 'working', lastEventAt: Date.now() })
|
||||
@@ -448,8 +538,24 @@ export class AgentHarnessService {
|
||||
}
|
||||
|
||||
async createAgent(input: CreateAgentInput): Promise<AgentDefinition> {
|
||||
if (input.adapter === 'hermes') {
|
||||
// Validate before touching the store so we don't leave an orphan
|
||||
// record on the unhappy path.
|
||||
assertHermesProviderInputValid(input)
|
||||
}
|
||||
|
||||
const agent = await this.agentStore.create(input)
|
||||
|
||||
if (agent.adapter === 'hermes') {
|
||||
try {
|
||||
await this.writeHermesPerAgentProvider(agent.id, input)
|
||||
} catch (err) {
|
||||
await this.agentStore.delete(agent.id).catch(() => {})
|
||||
throw err
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
if (agent.adapter !== 'openclaw') {
|
||||
return agent
|
||||
}
|
||||
@@ -490,6 +596,35 @@ export class AgentHarnessService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write Hermes' per-agent config.yaml + .env into the on-host home
|
||||
* dir. Caller must have already run assertHermesProviderInputValid;
|
||||
* any throw here is a real I/O failure and must roll back the agent
|
||||
* record.
|
||||
*/
|
||||
private async writeHermesPerAgentProvider(
|
||||
agentId: string,
|
||||
input: CreateAgentInput,
|
||||
): Promise<void> {
|
||||
// Non-null assertions are safe: assertHermesProviderInputValid ran
|
||||
// first and rejects when any required field is missing.
|
||||
const mapping = getHermesProviderMapping(input.providerType as string)
|
||||
if (!mapping) {
|
||||
throw new HermesProviderConfigInvalidError(
|
||||
`Provider type "${input.providerType}" is not supported by Hermes`,
|
||||
)
|
||||
}
|
||||
await writeHermesPerAgentProvider({
|
||||
browserosDir: this.browserosDir,
|
||||
agentId,
|
||||
providerId: mapping.hermesProvider,
|
||||
envVarName: mapping.envVarName,
|
||||
apiKey: (input.apiKey as string).trim(),
|
||||
modelId: (input.modelId as string).trim(),
|
||||
baseUrl: input.baseUrl?.trim() || mapping.defaultBaseUrl,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls every gateway-side OpenClaw agent into the harness store as a
|
||||
* harness record (idempotent, safe to call repeatedly). This lets
|
||||
@@ -599,9 +734,112 @@ export class AgentHarnessService {
|
||||
|
||||
async getHistory(agentId: string): Promise<AgentHistoryPage> {
|
||||
const agent = await this.requireAgent(agentId)
|
||||
// OpenClaw agents persist conversation in the gateway, not in the
|
||||
// AcpxRuntime's local session record. Reading the local record
|
||||
// would miss autonomous (cron / hook / channel) turns. Route
|
||||
// through the provisioner so the panel sees the full history.
|
||||
if (
|
||||
agent.adapter === 'openclaw' &&
|
||||
this.openclawProvisioner?.getAgentHistory
|
||||
) {
|
||||
return this.openclawProvisioner.getAgentHistory(agentId)
|
||||
}
|
||||
return this.runtime.getHistory({ agent, sessionId: 'main' })
|
||||
}
|
||||
|
||||
// ── Produced files (Files rail / inline artifact card) ───────────
|
||||
|
||||
/**
|
||||
* Outputs-rail data for one agent. Returns groups of files keyed
|
||||
* by the assistant turn that produced them, newest first. Empty
|
||||
* array when the agent hasn't produced anything yet, or when the
|
||||
* adapter doesn't track outputs (claude / codex — see Phase 2
|
||||
* commit).
|
||||
*/
|
||||
async listAgentFiles(
|
||||
agentId: string,
|
||||
options: { limit?: number } = {},
|
||||
): Promise<ProducedFilesRailGroup[]> {
|
||||
const agent = await this.requireAgent(agentId)
|
||||
const store = this.tryGetProducedFilesStore()
|
||||
if (!store) return []
|
||||
const rows = await store.listByAgent(agent.id, options)
|
||||
return store
|
||||
.groupByTurn(rows)
|
||||
.map(({ turnId, turnPrompt, createdAt, files }) => ({
|
||||
turnId,
|
||||
turnPrompt,
|
||||
createdAt,
|
||||
files: files.map(toProducedFileEntry),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline-card data for one assistant turn. Used by the SSE
|
||||
* `produced_files` event consumer to refresh metadata after the
|
||||
* turn completes; also handy for direct fetches by clients that
|
||||
* missed the live event.
|
||||
*/
|
||||
async listAgentFilesForTurn(
|
||||
agentId: string,
|
||||
turnId: string,
|
||||
): Promise<ProducedFileEntry[]> {
|
||||
await this.requireAgent(agentId)
|
||||
const store = this.tryGetProducedFilesStore()
|
||||
if (!store) return []
|
||||
const rows = await store.listByTurn(turnId)
|
||||
return rows.map(toProducedFileEntry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a preview payload for a single file. Returns null when the
|
||||
* file id is unknown OR the on-disk path no longer exists. The
|
||||
* route layer maps null → 404.
|
||||
*/
|
||||
async previewProducedFile(fileId: string): Promise<FilePreview | null> {
|
||||
const store = this.tryGetProducedFilesStore()
|
||||
if (!store) return null
|
||||
const row = await store.findById(fileId)
|
||||
if (!row) return null
|
||||
const agent = await this.agentStore.get(row.agentDefinitionId)
|
||||
if (!agent || agent.adapter !== 'openclaw') return null
|
||||
const workspaceDir = getHostWorkspaceDir(getOpenClawDir(), agent.name)
|
||||
const resolved = await store.resolveFilePath({ fileId, workspaceDir })
|
||||
if (!resolved) return null
|
||||
return buildFilePreview(resolved.absolutePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a file id to an absolute on-disk path + metadata for the
|
||||
* download route to stream. Null when the file id is unknown or
|
||||
* the path escaped the workspace root (containment check happens
|
||||
* inside `producedFilesStore.resolveFilePath`).
|
||||
*/
|
||||
async resolveProducedFileForDownload(fileId: string): Promise<{
|
||||
absolutePath: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
} | null> {
|
||||
const store = this.tryGetProducedFilesStore()
|
||||
if (!store) return null
|
||||
const row = await store.findById(fileId)
|
||||
if (!row) return null
|
||||
const agent = await this.agentStore.get(row.agentDefinitionId)
|
||||
if (!agent || agent.adapter !== 'openclaw') return null
|
||||
const workspaceDir = getHostWorkspaceDir(getOpenClawDir(), agent.name)
|
||||
const resolved = await store.resolveFilePath({ fileId, workspaceDir })
|
||||
if (!resolved) return null
|
||||
const mimeType = await detectMimeType(resolved.absolutePath)
|
||||
const fileName = basename(row.path)
|
||||
return {
|
||||
absolutePath: resolved.absolutePath,
|
||||
fileName,
|
||||
mimeType,
|
||||
size: row.size,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick off a new agent turn that survives the caller's HTTP lifetime.
|
||||
* Events are pushed into a per-turn buffer; the returned `frames`
|
||||
@@ -627,6 +865,7 @@ export class AgentHarnessService {
|
||||
prompt: input.message,
|
||||
})
|
||||
this.notifyTurnStarted(agent.id)
|
||||
this.emitTurnLifecycle(agent, { type: 'turn_started' })
|
||||
|
||||
// Kick off the runtime call in the background. The per-turn
|
||||
// AbortController — NOT the HTTP request signal — is what cancels
|
||||
@@ -728,6 +967,26 @@ export class AgentHarnessService {
|
||||
const turn = this.turnRegistry.get(turnId)
|
||||
if (!turn) return
|
||||
let lastErrorMessage: string | undefined
|
||||
|
||||
// Bracket openclaw turns with a workspace snapshot so any file the
|
||||
// agent produces during the turn is attributable back to it (rail
|
||||
// + inline artifact UX). Adapter-gated for v1 — Claude / Codex
|
||||
// write to the user's host filesystem and don't need this; their
|
||||
// outputs are already visible via the user's own tools.
|
||||
const isOpenclaw = agent.adapter === 'openclaw'
|
||||
const workspaceDir = isOpenclaw ? this.resolveSafeWorkspaceDir(agent) : null
|
||||
const producedFilesStore = workspaceDir
|
||||
? this.tryGetProducedFilesStore()
|
||||
: null
|
||||
const workspaceSnapshot =
|
||||
workspaceDir && producedFilesStore
|
||||
? await this.snapshotWorkspaceForTurn(
|
||||
agent,
|
||||
workspaceDir,
|
||||
producedFilesStore,
|
||||
)
|
||||
: null
|
||||
|
||||
try {
|
||||
const upstream = await this.runtime.send({
|
||||
agent,
|
||||
@@ -746,6 +1005,7 @@ export class AgentHarnessService {
|
||||
if (done) break
|
||||
if (value.type === 'error') lastErrorMessage = value.message
|
||||
this.turnRegistry.pushEvent(turnId, value)
|
||||
this.emitTurnLifecycle(agent, { type: 'turn_event', event: value })
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
@@ -782,10 +1042,141 @@ export class AgentHarnessService {
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
// Attribute any files the agent produced during this turn. We
|
||||
// run on success, error, AND inside `finally` so an upstream
|
||||
// failure mid-turn that still managed to write files doesn't
|
||||
// lose them. We skip only when the user explicitly cancelled —
|
||||
// in that case the side effects shouldn't be surfaced as
|
||||
// "outputs you asked for."
|
||||
if (
|
||||
workspaceDir &&
|
||||
workspaceSnapshot !== null &&
|
||||
producedFilesStore &&
|
||||
!turn.abortController.signal.aborted
|
||||
) {
|
||||
await this.attributeTurnFiles({
|
||||
producedFilesStore,
|
||||
workspaceDir,
|
||||
before: workspaceSnapshot,
|
||||
agent,
|
||||
turnId,
|
||||
turnPrompt: input.message,
|
||||
})
|
||||
}
|
||||
this.notifyTurnEnded(agent.id, {
|
||||
ok: lastErrorMessage === undefined,
|
||||
error: lastErrorMessage,
|
||||
})
|
||||
this.emitTurnLifecycle(agent, {
|
||||
type: 'turn_ended',
|
||||
error: lastErrorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the host-side workspace dir for an openclaw agent,
|
||||
* returning `null` when the agent's display name fails the
|
||||
* path-traversal guard. Logs a warning so the safety-disabled
|
||||
* case is observable in production.
|
||||
*/
|
||||
private resolveSafeWorkspaceDir(agent: AgentDefinition): string | null {
|
||||
try {
|
||||
return getHostWorkspaceDir(getOpenClawDir(), agent.name)
|
||||
} catch (err) {
|
||||
logger.warn('Skipping openclaw file attribution: unsafe agent name', {
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-turn workspace snapshot. Returns `null` on any failure so
|
||||
* the rest of the turn flow continues without file attribution.
|
||||
*/
|
||||
private async snapshotWorkspaceForTurn(
|
||||
agent: AgentDefinition,
|
||||
workspaceDir: string,
|
||||
producedFilesStore: ProducedFilesStore,
|
||||
): Promise<FileSnapshot | null> {
|
||||
try {
|
||||
return await producedFilesStore.snapshotWorkspace(workspaceDir)
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'Failed to snapshot openclaw workspace; file attribution disabled for this turn',
|
||||
{
|
||||
agentId: agent.id,
|
||||
workspaceDir,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily resolve the produced-files store. Returns `null` if the
|
||||
* SQLite handle isn't initialised yet — keeps the harness usable in
|
||||
* tests + during early server boot, where chat turns are unlikely
|
||||
* but allowed.
|
||||
*/
|
||||
private tryGetProducedFilesStore(): ProducedFilesStore | null {
|
||||
if (this.explicitProducedFilesStore) return this.explicitProducedFilesStore
|
||||
if (this.cachedProducedFilesStore) return this.cachedProducedFilesStore
|
||||
try {
|
||||
this.cachedProducedFilesStore = new ProducedFilesStore()
|
||||
return this.cachedProducedFilesStore
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'Produced-files store unavailable; turn-level file attribution disabled',
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff the workspace, persist new/modified files, and emit a
|
||||
* `produced_files` event so subscribers can render the inline
|
||||
* artifact card. Tolerant of all errors — a failure here must
|
||||
* never block the rest of the turn-end bookkeeping.
|
||||
*/
|
||||
private async attributeTurnFiles(input: {
|
||||
producedFilesStore: ProducedFilesStore
|
||||
workspaceDir: string
|
||||
before: FileSnapshot
|
||||
agent: AgentDefinition
|
||||
turnId: string
|
||||
turnPrompt: string
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const rows = await input.producedFilesStore.finalizeTurn({
|
||||
agentDefinitionId: input.agent.id,
|
||||
sessionKey: input.agent.sessionKey,
|
||||
turnId: input.turnId,
|
||||
turnPrompt: input.turnPrompt,
|
||||
workspaceDir: input.workspaceDir,
|
||||
before: input.before,
|
||||
})
|
||||
if (rows.length === 0) return
|
||||
this.turnRegistry.pushEvent(input.turnId, {
|
||||
type: 'produced_files',
|
||||
files: rows.map((row) => ({
|
||||
id: row.id,
|
||||
path: row.path,
|
||||
size: row.size,
|
||||
mtimeMs: row.mtimeMs,
|
||||
})),
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn('Failed to attribute produced files for turn', {
|
||||
agentId: input.agent.id,
|
||||
turnId: input.turnId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -846,6 +1237,48 @@ export class InvalidAgentUpdateError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a Hermes adapter agent is created without a complete
|
||||
* provider config (provider type, API key, model id; base URL when the
|
||||
* provider mapping requires it). Surfaces as a 400 in the route layer.
|
||||
*/
|
||||
export class HermesProviderConfigInvalidError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'HermesProviderConfigInvalidError'
|
||||
}
|
||||
}
|
||||
|
||||
function assertHermesProviderInputValid(input: CreateAgentInput): void {
|
||||
const providerType = input.providerType?.trim()
|
||||
if (!providerType) {
|
||||
throw new HermesProviderConfigInvalidError(
|
||||
'Hermes agent requires providerType (pick a provider configured in BrowserOS AI Settings)',
|
||||
)
|
||||
}
|
||||
const mapping = getHermesProviderMapping(providerType)
|
||||
if (!mapping) {
|
||||
throw new HermesProviderConfigInvalidError(
|
||||
`Provider type "${providerType}" is not supported by Hermes`,
|
||||
)
|
||||
}
|
||||
if (!input.apiKey?.trim()) {
|
||||
throw new HermesProviderConfigInvalidError(
|
||||
'Hermes agent requires apiKey from the selected provider',
|
||||
)
|
||||
}
|
||||
if (!input.modelId?.trim()) {
|
||||
throw new HermesProviderConfigInvalidError(
|
||||
'Hermes agent requires modelId from the selected provider',
|
||||
)
|
||||
}
|
||||
if (mapping.requiresBaseUrl && !input.baseUrl?.trim()) {
|
||||
throw new HermesProviderConfigInvalidError(
|
||||
`Provider type "${providerType}" requires baseUrl`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when `startTurn` is called for an agent that already has an
|
||||
* in-flight turn. The route layer maps this to 409 + the existing
|
||||
@@ -860,3 +1293,38 @@ export class TurnAlreadyActiveError extends Error {
|
||||
this.name = 'TurnAlreadyActiveError'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Files API DTO ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wire shape for one produced-file entry returned by the rail and
|
||||
* inline-card endpoints. Trimmed from the on-disk row — clients
|
||||
* never see `agentDefinitionId` or `sessionKey`.
|
||||
*/
|
||||
export interface ProducedFileEntry {
|
||||
id: string
|
||||
path: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
createdAt: number
|
||||
detectedBy: 'diff' | 'tool'
|
||||
}
|
||||
|
||||
export interface ProducedFilesRailGroup {
|
||||
turnId: string
|
||||
/** First non-blank line of the user prompt that initiated this turn. */
|
||||
turnPrompt: string
|
||||
createdAt: number
|
||||
files: ProducedFileEntry[]
|
||||
}
|
||||
|
||||
function toProducedFileEntry(row: ProducedFileRow): ProducedFileEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
path: row.path,
|
||||
size: row.size,
|
||||
mtimeMs: row.mtimeMs,
|
||||
createdAt: row.createdAt,
|
||||
detectedBy: row.detectedBy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Host-side path helpers for the Hermes container.
|
||||
*
|
||||
* Hermes per-agent state lives under the BrowserOS-managed VM state
|
||||
* directory (so it's reachable inside the Lima VM via the existing
|
||||
* vm/ → /mnt/browseros/vm bind mount). The Hermes container then bind-
|
||||
* mounts the guest-side path (/mnt/browseros/vm/hermes/harness) into
|
||||
* /data/agents/harness, so `HERMES_HOME` ends up pointing at a path
|
||||
* the container can actually open.
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { getVmStateDir } from '../../../lib/browseros-dir'
|
||||
|
||||
/** Top-level Hermes state directory: `<browserosDir>/vm/hermes`. */
|
||||
export function getHermesHostStateDir(browserosDir?: string): string {
|
||||
return join(
|
||||
browserosDir ? join(browserosDir, 'vm') : getVmStateDir(),
|
||||
'hermes',
|
||||
)
|
||||
}
|
||||
|
||||
/** Per-agent harness root: `<browserosDir>/vm/hermes/harness`. */
|
||||
export function getHermesHarnessHostDir(browserosDir?: string): string {
|
||||
return join(getHermesHostStateDir(browserosDir), 'harness')
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-agent home directory on the host. The Hermes container reads
|
||||
* `config.yaml` + `.env` from here via the harness bind mount; both
|
||||
* files are written at agent-create time by AgentHarnessService and
|
||||
* stay constant across turns.
|
||||
*/
|
||||
export function getHermesAgentHomeHostDir(input: {
|
||||
browserosDir?: string
|
||||
agentId: string
|
||||
}): string {
|
||||
return join(
|
||||
getHermesHarnessHostDir(input.browserosDir),
|
||||
input.agentId,
|
||||
'home',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a Hermes per-agent provider config into the on-host home dir.
|
||||
* The dir lives under <browserosDir>/vm/hermes/harness/<agentId>/home/
|
||||
* which is bind-mounted into the container at /data/agents/harness/<id>/home/.
|
||||
*
|
||||
* Idempotent: writes always overwrite (last-write-wins). The provider
|
||||
* id, env var name, and credentials must be supplied by the caller —
|
||||
* Hermes agents always carry their own config; there is no
|
||||
* `~/.hermes/` fallback.
|
||||
*/
|
||||
export async function writeHermesPerAgentProvider(input: {
|
||||
browserosDir?: string
|
||||
agentId: string
|
||||
providerId: string
|
||||
envVarName: string
|
||||
apiKey: string
|
||||
modelId: string
|
||||
baseUrl?: string
|
||||
}): Promise<void> {
|
||||
const home = getHermesAgentHomeHostDir({
|
||||
browserosDir: input.browserosDir,
|
||||
agentId: input.agentId,
|
||||
})
|
||||
await mkdir(home, { recursive: true })
|
||||
|
||||
// Hermes' `provider: custom` requires a `base_url` — without one the
|
||||
// model loader rejects with `unknown provider 'custom'`. Callers that
|
||||
// use a named Hermes provider (e.g. anthropic, openrouter) can omit
|
||||
// baseUrl and Hermes resolves the URL itself.
|
||||
if (input.providerId === 'custom' && !input.baseUrl) {
|
||||
throw new Error(
|
||||
'Hermes provider "custom" requires base_url; set HermesProviderMapping.defaultBaseUrl or supply input.baseUrl',
|
||||
)
|
||||
}
|
||||
const yamlLines = [
|
||||
'model:',
|
||||
` default: ${JSON.stringify(input.modelId)}`,
|
||||
` provider: ${JSON.stringify(input.providerId)}`,
|
||||
]
|
||||
if (input.baseUrl) {
|
||||
yamlLines.push(` base_url: ${JSON.stringify(input.baseUrl)}`)
|
||||
}
|
||||
yamlLines.push('')
|
||||
await writeFile(join(home, 'config.yaml'), yamlLines.join('\n'), {
|
||||
mode: 0o600,
|
||||
})
|
||||
|
||||
const envLines: string[] = [`${input.envVarName}=${input.apiKey}`, '']
|
||||
await writeFile(join(home, '.env'), envLines.join('\n'), { mode: 0o600 })
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Translation table from BrowserOS LLM provider types (the values that
|
||||
* live in `LlmProviderConfig.type` on the extension side) to Hermes
|
||||
* runtime configuration. Hermes itself only knows a small fixed set of
|
||||
* provider keys; BrowserOS exposes a richer registry, so we explicitly
|
||||
* gate which BrowserOS provider types Hermes can consume.
|
||||
*
|
||||
* The set of allowed BrowserOS provider types is shared with the
|
||||
* frontend via `HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES`. Adding a
|
||||
* new type there without an entry here will fail the type check below
|
||||
* (every supported type must have a mapping).
|
||||
*
|
||||
* Anything not listed is rejected at agent-create time with a clear
|
||||
* error — there is no `~/.hermes/` fallback.
|
||||
*/
|
||||
import {
|
||||
HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES,
|
||||
type HermesSupportedBrowserosProviderType,
|
||||
} from '@browseros/shared/constants/hermes'
|
||||
|
||||
export interface HermesProviderMapping {
|
||||
/** Hermes' own provider key written into `model.provider` in config.yaml. */
|
||||
hermesProvider: string
|
||||
/** Env var Hermes reads the API key from (written into per-agent `.env`). */
|
||||
envVarName: string
|
||||
/** True when the harness must require an explicit baseUrl from input. */
|
||||
requiresBaseUrl: boolean
|
||||
/**
|
||||
* Used when `hermesProvider === 'custom'` and the input has no
|
||||
* baseUrl — Hermes treats `provider: custom` as "call this URL
|
||||
* directly", so `base_url` must always end up in config.yaml.
|
||||
*/
|
||||
defaultBaseUrl?: string
|
||||
}
|
||||
|
||||
const HERMES_PROVIDER_MAP: Record<
|
||||
HermesSupportedBrowserosProviderType,
|
||||
HermesProviderMapping
|
||||
> = {
|
||||
anthropic: {
|
||||
hermesProvider: 'anthropic',
|
||||
envVarName: 'ANTHROPIC_API_KEY',
|
||||
requiresBaseUrl: false,
|
||||
},
|
||||
// Hermes (v2026.4.x) has no provider key named `"openai"`. Per the
|
||||
// upstream docs, `provider: custom` + `base_url` is the canonical
|
||||
// shape for any OpenAI-compatible endpoint with an API key — Hermes
|
||||
// skips provider lookup and calls the URL directly. Used for both
|
||||
// pure OpenAI (default base URL) and openai-compatible (caller URL).
|
||||
openai: {
|
||||
hermesProvider: 'custom',
|
||||
envVarName: 'OPENAI_API_KEY',
|
||||
requiresBaseUrl: false,
|
||||
defaultBaseUrl: 'https://api.openai.com/v1',
|
||||
},
|
||||
openrouter: {
|
||||
hermesProvider: 'openrouter',
|
||||
envVarName: 'OPENROUTER_API_KEY',
|
||||
requiresBaseUrl: false,
|
||||
},
|
||||
'openai-compatible': {
|
||||
hermesProvider: 'custom',
|
||||
envVarName: 'OPENAI_API_KEY',
|
||||
requiresBaseUrl: true,
|
||||
},
|
||||
}
|
||||
|
||||
export function isHermesSupportedProviderType(
|
||||
providerType: string,
|
||||
): providerType is HermesSupportedBrowserosProviderType {
|
||||
return (
|
||||
HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES as readonly string[]
|
||||
).includes(providerType)
|
||||
}
|
||||
|
||||
export function getHermesProviderMapping(
|
||||
providerType: string,
|
||||
): HermesProviderMapping | undefined {
|
||||
if (!isHermesSupportedProviderType(providerType)) return undefined
|
||||
return HERMES_PROVIDER_MAP[providerType]
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { cpSync, existsSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { getBrowserosDir } from '../../../lib/browseros-dir'
|
||||
import { ContainerCli, ImageLoader } from '../../../lib/container'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import {
|
||||
getLimaHomeDir,
|
||||
resolveBundledLimactl,
|
||||
resolveBundledLimaTemplate,
|
||||
VM_NAME,
|
||||
VmRuntime,
|
||||
} from '../../../lib/vm'
|
||||
import { VM_TELEMETRY_EVENTS } from '../../../lib/vm/telemetry'
|
||||
import { ContainerRuntime } from './container-runtime'
|
||||
|
||||
const UNSUPPORTED_PLATFORM_MESSAGE =
|
||||
'browseros-vm currently supports macOS only; see the Linux/Windows tracking issue'
|
||||
|
||||
export interface ContainerRuntimeFactoryInput {
|
||||
resourcesDir?: string
|
||||
projectDir: string
|
||||
browserosRoot?: string
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
export function buildContainerRuntime(
|
||||
input: ContainerRuntimeFactoryInput,
|
||||
): ContainerRuntime {
|
||||
const platform = input.platform ?? process.platform
|
||||
if (platform !== 'darwin') {
|
||||
// BROWSEROS_SKIP_OPENCLAW=1 is the explicit opt-in for non-darwin hosts
|
||||
// (e.g. Linux CI runners) where OpenClaw can't actually run but the rest
|
||||
// of the server should still come up. Returns a no-op runtime — any
|
||||
// OpenClaw API call hitting it will fail loudly at request time.
|
||||
if (
|
||||
process.env.NODE_ENV === 'test' ||
|
||||
process.env.BROWSEROS_SKIP_OPENCLAW === '1'
|
||||
) {
|
||||
return new UnsupportedPlatformTestRuntime(input.projectDir)
|
||||
}
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
const browserosRoot = input.browserosRoot ?? getBrowserosDir()
|
||||
if (input.resourcesDir) {
|
||||
migrateLegacyOpenClawDirSync(browserosRoot)
|
||||
}
|
||||
|
||||
const limactlPath = input.resourcesDir
|
||||
? resolveBundledLimactl(input.resourcesDir)
|
||||
: 'limactl'
|
||||
const limaHome = getLimaHomeDir(browserosRoot)
|
||||
const vm = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
templatePath: input.resourcesDir
|
||||
? resolveBundledLimaTemplate(input.resourcesDir)
|
||||
: undefined,
|
||||
browserosRoot,
|
||||
})
|
||||
const shell = new ContainerCli({ limactlPath, limaHome, vmName: VM_NAME })
|
||||
const loader = new ImageLoader(shell)
|
||||
|
||||
return new ContainerRuntime({
|
||||
vm,
|
||||
shell,
|
||||
loader,
|
||||
projectDir: input.projectDir,
|
||||
})
|
||||
}
|
||||
|
||||
export async function migrateLegacyOpenClawDir(
|
||||
browserosRoot = getBrowserosDir(),
|
||||
): Promise<void> {
|
||||
migrateLegacyOpenClawDirSync(browserosRoot)
|
||||
}
|
||||
|
||||
function migrateLegacyOpenClawDirSync(browserosRoot = getBrowserosDir()): void {
|
||||
const legacyDir = join(browserosRoot, 'openclaw')
|
||||
const nextDir = join(browserosRoot, 'vm', 'openclaw')
|
||||
if (!existsSync(legacyDir)) return
|
||||
if (existsSync(nextDir)) {
|
||||
logger.warn('OpenClaw legacy and VM state directories both exist', {
|
||||
legacyDir,
|
||||
nextDir,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
mkdirSync(dirname(nextDir), { recursive: true })
|
||||
cpSync(legacyDir, nextDir, { recursive: true })
|
||||
logger.info(VM_TELEMETRY_EVENTS.migrationOpenClawMoved, {
|
||||
from: legacyDir,
|
||||
to: nextDir,
|
||||
})
|
||||
}
|
||||
|
||||
class UnsupportedPlatformTestRuntime extends ContainerRuntime {
|
||||
constructor(projectDir: string) {
|
||||
super({
|
||||
vm: {} as VmRuntime,
|
||||
shell: {} as ContainerCli,
|
||||
loader: {
|
||||
ensureImageLoaded: rejectUnsupportedPlatform,
|
||||
ensureAgentImageLoaded: rejectUnsupportedPlatform,
|
||||
},
|
||||
projectDir,
|
||||
})
|
||||
}
|
||||
|
||||
override async ensureReady(): Promise<void> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async isPodmanAvailable(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
override async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
return { initialized: false, running: false }
|
||||
}
|
||||
|
||||
override async pullImage(): Promise<void> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async prewarmGatewayImage(): Promise<void> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async isGatewayCurrent(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
override async startGateway(): Promise<void> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async stopGateway(): Promise<void> {}
|
||||
|
||||
override async restartGateway(): Promise<void> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async getGatewayLogs(): Promise<string[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
override async isHealthy(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
override async isReady(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
override async waitForReady(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
override async stopVm(): Promise<void> {}
|
||||
|
||||
override async execInContainer(): Promise<number> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async runInContainer(): Promise<never> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async runGatewaySetupCommand(): Promise<number> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override tailGatewayLogs(): () => void {
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectUnsupportedPlatform(): Promise<never> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
function unsupportedPlatformError(): Error {
|
||||
return new Error(UNSUPPORTED_PLATFORM_MESSAGE)
|
||||
}
|
||||
@@ -1,439 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import {
|
||||
OPENCLAW_AGENT_NAME,
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
OPENCLAW_IMAGE,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import type {
|
||||
ContainerCli,
|
||||
ContainerCommandResult,
|
||||
ContainerSpec,
|
||||
LogFn,
|
||||
WaitForContainerNameReleaseOptions,
|
||||
} from '../../../lib/container'
|
||||
import { isContainerNameInUse } from '../../../lib/container'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import {
|
||||
GUEST_VM_STATE,
|
||||
hostPathToGuest,
|
||||
type VmRuntime,
|
||||
} from '../../../lib/vm'
|
||||
import { ContainerNameInUseError } from '../../../lib/vm/errors'
|
||||
|
||||
const GATEWAY_CONTAINER_HOME = '/home/node'
|
||||
const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
|
||||
const GUEST_OPENCLAW_HOME = `${GUEST_VM_STATE}/openclaw`
|
||||
const GATEWAY_NPM_PREFIX = `${GATEWAY_CONTAINER_HOME}/.npm-global`
|
||||
const CREATE_CONTAINER_MAX_ATTEMPTS = 3
|
||||
const OPENCLAW_NAME_RELEASE_WAIT: WaitForContainerNameReleaseOptions = {
|
||||
timeoutMs: 10_000,
|
||||
intervalMs: 100,
|
||||
}
|
||||
// Prepend user-installed bin so tools like `claude` / `gemini` CLI that
|
||||
// are installed via npm into the mounted home are discoverable by
|
||||
// OpenClaw's child-process spawns (no login shell is involved).
|
||||
const GATEWAY_PATH = [
|
||||
`${GATEWAY_NPM_PREFIX}/bin`,
|
||||
'/usr/local/sbin',
|
||||
'/usr/local/bin',
|
||||
'/usr/sbin',
|
||||
'/usr/bin',
|
||||
'/sbin',
|
||||
'/bin',
|
||||
].join(':')
|
||||
|
||||
export type GatewayContainerSpec = {
|
||||
hostPort: number
|
||||
hostHome: string
|
||||
envFilePath: string
|
||||
gatewayToken?: string
|
||||
timezone: string
|
||||
}
|
||||
|
||||
export interface ContainerRuntimeConfig {
|
||||
vm: VmRuntime
|
||||
shell: ContainerCli
|
||||
loader: {
|
||||
ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void>
|
||||
ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise<string>
|
||||
}
|
||||
projectDir: string
|
||||
}
|
||||
|
||||
export class ContainerRuntime {
|
||||
private readonly vm: VmRuntime
|
||||
private readonly shell: ContainerCli
|
||||
private readonly loader: {
|
||||
ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void>
|
||||
ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise<string>
|
||||
}
|
||||
private readonly projectDir: string
|
||||
|
||||
constructor(config: ContainerRuntimeConfig) {
|
||||
this.vm = config.vm
|
||||
this.shell = config.shell
|
||||
this.loader = config.loader
|
||||
this.projectDir = config.projectDir
|
||||
}
|
||||
|
||||
async ensureReady(onLog?: LogFn): Promise<void> {
|
||||
logger.info('Ensuring BrowserOS VM runtime readiness')
|
||||
await this.vm.ensureReady(onLog)
|
||||
await this.vm.getDefaultGateway()
|
||||
}
|
||||
|
||||
async isPodmanAvailable(): Promise<boolean> {
|
||||
return true
|
||||
}
|
||||
|
||||
async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
const running = await this.vm.isReady()
|
||||
return { initialized: running, running }
|
||||
}
|
||||
|
||||
async pullImage(image: string, onLog?: LogFn): Promise<void> {
|
||||
await this.loader.ensureImageLoaded(image, onLog)
|
||||
}
|
||||
|
||||
/** Warm the gateway image in containerd without creating or starting containers. */
|
||||
async prewarmGatewayImage(onLog?: LogFn): Promise<void> {
|
||||
await this.ensureGatewayImageLoaded(onLog)
|
||||
}
|
||||
|
||||
/** Report whether the existing gateway container was created from the target image. */
|
||||
async isGatewayCurrent(): Promise<boolean> {
|
||||
const image = await this.shell.containerImageRef(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
)
|
||||
const expected = this.expectedGatewayImageRef()
|
||||
const current = imageMatchesExpectedRef(image, expected)
|
||||
if (!current) {
|
||||
logger.info('OpenClaw gateway image is not current', {
|
||||
actualImageRef: image,
|
||||
expectedImageRef: expected,
|
||||
})
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
async startGateway(
|
||||
input: GatewayContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
const image = await this.ensureGatewayImageLoaded(onLog)
|
||||
const container = await this.buildGatewayContainerSpec(input, image)
|
||||
await this.createContainerWithNameReconcile(container, onLog)
|
||||
await this.shell.startContainer(container.name)
|
||||
}
|
||||
|
||||
async stopGateway(onLog?: LogFn): Promise<void> {
|
||||
await this.removeGatewayContainer(onLog)
|
||||
}
|
||||
|
||||
async restartGateway(
|
||||
input: GatewayContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
await this.startGateway(input, onLog)
|
||||
}
|
||||
|
||||
async getGatewayLogs(tail = 50): Promise<string[]> {
|
||||
const lines: string[] = []
|
||||
await this.shell.runCommand(
|
||||
['logs', '-n', String(tail), OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
(line) => lines.push(line),
|
||||
)
|
||||
return lines
|
||||
}
|
||||
|
||||
async isHealthy(hostPort: number): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${hostPort}/healthz`)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async isReady(hostPort: number): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${hostPort}/readyz`)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async waitForReady(hostPort: number, timeoutMs = 30_000): Promise<boolean> {
|
||||
logger.info('Waiting for OpenClaw gateway readiness', {
|
||||
hostPort,
|
||||
timeoutMs,
|
||||
})
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await this.isReady(hostPort)) return true
|
||||
await Bun.sleep(1000)
|
||||
}
|
||||
logger.error('Timed out waiting for OpenClaw gateway readiness', {
|
||||
hostPort,
|
||||
timeoutMs,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
async stopVm(): Promise<void> {
|
||||
await this.vm.stopVm()
|
||||
}
|
||||
|
||||
async execInContainer(command: string[], onLog?: LogFn): Promise<number> {
|
||||
return this.shell.exec(OPENCLAW_GATEWAY_CONTAINER_NAME, command, onLog)
|
||||
}
|
||||
|
||||
// Unlike execInContainer, this returns stdout and stderr separately
|
||||
// so callers that need to parse program output (e.g. JSON status
|
||||
// commands) aren't forced to untangle it from nerdctl's stderr.
|
||||
async runInContainer(command: string[]): Promise<ContainerCommandResult> {
|
||||
return this.shell.runCommand([
|
||||
'exec',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
...command,
|
||||
])
|
||||
}
|
||||
|
||||
async runGatewaySetupCommand(
|
||||
command: string[],
|
||||
spec: GatewayContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<number> {
|
||||
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
|
||||
await this.removeContainerAndWait(setupContainerName, onLog)
|
||||
const image = await this.ensureGatewayImageLoaded(onLog)
|
||||
const setupArgs = command[0] === 'node' ? command.slice(1) : command
|
||||
const createResult = await this.runSetupCreateWithNameReconcile(
|
||||
setupContainerName,
|
||||
[
|
||||
'create',
|
||||
'--name',
|
||||
setupContainerName,
|
||||
...(await this.buildGatewayRunArgs(spec)),
|
||||
image,
|
||||
'node',
|
||||
...setupArgs,
|
||||
],
|
||||
onLog,
|
||||
)
|
||||
if (createResult.exitCode !== 0) {
|
||||
await this.shell.removeContainer(
|
||||
setupContainerName,
|
||||
{ force: true },
|
||||
onLog,
|
||||
)
|
||||
return createResult.exitCode
|
||||
}
|
||||
|
||||
try {
|
||||
const startResult = await this.shell.runCommand(
|
||||
['start', '-a', setupContainerName],
|
||||
onLog,
|
||||
)
|
||||
return startResult.exitCode
|
||||
} finally {
|
||||
await this.shell.removeContainer(
|
||||
setupContainerName,
|
||||
{ force: true },
|
||||
onLog,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tailGatewayLogs(onLine: LogFn): () => void {
|
||||
return this.shell.tailLogs(OPENCLAW_GATEWAY_CONTAINER_NAME, onLine)
|
||||
}
|
||||
|
||||
private async removeGatewayContainer(onLog?: LogFn): Promise<void> {
|
||||
await this.removeContainerAndWait(OPENCLAW_GATEWAY_CONTAINER_NAME, onLog)
|
||||
}
|
||||
|
||||
/** Create the fixed-name gateway after reconciling stale nerdctl name ownership. */
|
||||
private async createContainerWithNameReconcile(
|
||||
container: ContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
let attempt = 1
|
||||
while (true) {
|
||||
await this.removeContainerAndWait(container.name, onLog)
|
||||
try {
|
||||
await this.shell.createContainer(container, onLog)
|
||||
return
|
||||
} catch (err) {
|
||||
if (
|
||||
!(err instanceof ContainerNameInUseError) ||
|
||||
attempt >= CREATE_CONTAINER_MAX_ATTEMPTS
|
||||
) {
|
||||
throw err
|
||||
}
|
||||
logger.warn('OpenClaw container name still in use; retrying create', {
|
||||
containerName: container.name,
|
||||
attempt,
|
||||
maxAttempts: CREATE_CONTAINER_MAX_ATTEMPTS,
|
||||
})
|
||||
attempt++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runSetupCreateWithNameReconcile(
|
||||
setupContainerName: string,
|
||||
createArgs: string[],
|
||||
onLog?: LogFn,
|
||||
): Promise<ContainerCommandResult> {
|
||||
let attempt = 1
|
||||
while (true) {
|
||||
const result = await this.shell.runCommand(createArgs, onLog)
|
||||
if (
|
||||
result.exitCode === 0 ||
|
||||
!isContainerNameInUse(result.stderr) ||
|
||||
attempt >= CREATE_CONTAINER_MAX_ATTEMPTS
|
||||
) {
|
||||
return result
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
'OpenClaw setup container name still in use; retrying create',
|
||||
{
|
||||
containerName: setupContainerName,
|
||||
attempt,
|
||||
maxAttempts: CREATE_CONTAINER_MAX_ATTEMPTS,
|
||||
},
|
||||
)
|
||||
await this.removeContainerAndWait(setupContainerName, onLog)
|
||||
attempt++
|
||||
}
|
||||
}
|
||||
|
||||
private async removeContainerAndWait(
|
||||
containerName: string,
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
await this.shell.removeContainer(containerName, { force: true }, onLog)
|
||||
await this.shell.waitForContainerNameRelease(
|
||||
containerName,
|
||||
OPENCLAW_NAME_RELEASE_WAIT,
|
||||
)
|
||||
}
|
||||
|
||||
private async buildGatewayContainerSpec(
|
||||
input: GatewayContainerSpec,
|
||||
image: string,
|
||||
): Promise<ContainerSpec> {
|
||||
return {
|
||||
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
image,
|
||||
restart: 'unless-stopped',
|
||||
ports: [
|
||||
{
|
||||
hostIp: '127.0.0.1',
|
||||
hostPort: input.hostPort,
|
||||
containerPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
},
|
||||
],
|
||||
envFile: this.translateHostPath(input.envFilePath, input.hostHome),
|
||||
env: this.buildGatewayEnv(input),
|
||||
mounts: [{ source: GUEST_OPENCLAW_HOME, target: GATEWAY_CONTAINER_HOME }],
|
||||
addHosts: [await this.hostContainersInternalEntry()],
|
||||
health: {
|
||||
cmd: `curl -sf http://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}/healthz`,
|
||||
interval: '30s',
|
||||
timeout: '10s',
|
||||
retries: 3,
|
||||
},
|
||||
command: [
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'gateway',
|
||||
'--bind',
|
||||
'lan',
|
||||
'--port',
|
||||
String(OPENCLAW_GATEWAY_CONTAINER_PORT),
|
||||
'--allow-unconfigured',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
private async buildGatewayRunArgs(
|
||||
input: GatewayContainerSpec,
|
||||
): Promise<string[]> {
|
||||
const args = [
|
||||
'--env-file',
|
||||
this.translateHostPath(input.envFilePath, input.hostHome),
|
||||
'-v',
|
||||
`${GUEST_OPENCLAW_HOME}:${GATEWAY_CONTAINER_HOME}`,
|
||||
]
|
||||
for (const [key, value] of Object.entries(this.buildGatewayEnv(input))) {
|
||||
args.push('-e', `${key}=${value}`)
|
||||
}
|
||||
args.push('--add-host', await this.hostContainersInternalEntry())
|
||||
return args
|
||||
}
|
||||
|
||||
private async hostContainersInternalEntry(): Promise<string> {
|
||||
return `host.containers.internal:${await this.vm.getDefaultGateway()}`
|
||||
}
|
||||
|
||||
private async ensureGatewayImageLoaded(onLog?: LogFn): Promise<string> {
|
||||
// Local image testing can override the pinned GHCR image with OPENCLAW_IMAGE.
|
||||
const override = process.env.OPENCLAW_IMAGE?.trim()
|
||||
if (override) {
|
||||
await this.loader.ensureImageLoaded(override, onLog)
|
||||
return override
|
||||
}
|
||||
return this.loader.ensureAgentImageLoaded(OPENCLAW_AGENT_NAME, onLog)
|
||||
}
|
||||
|
||||
private expectedGatewayImageRef(): string {
|
||||
return process.env.OPENCLAW_IMAGE?.trim() || OPENCLAW_IMAGE
|
||||
}
|
||||
|
||||
private buildGatewayEnv(input: GatewayContainerSpec): Record<string, string> {
|
||||
return {
|
||||
HOME: GATEWAY_CONTAINER_HOME,
|
||||
OPENCLAW_HOME: GATEWAY_CONTAINER_HOME,
|
||||
OPENCLAW_STATE_DIR: GATEWAY_STATE_DIR,
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
NODE_COMPILE_CACHE: '/var/tmp/openclaw-compile-cache',
|
||||
NODE_ENV: 'production',
|
||||
TZ: input.timezone,
|
||||
PATH: GATEWAY_PATH,
|
||||
NPM_CONFIG_PREFIX: GATEWAY_NPM_PREFIX,
|
||||
...(input.gatewayToken
|
||||
? { OPENCLAW_GATEWAY_TOKEN: input.gatewayToken }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
private translateHostPath(path: string, openclawHostDir: string): string {
|
||||
if (path === openclawHostDir) return GUEST_OPENCLAW_HOME
|
||||
if (path.startsWith(`${openclawHostDir}/`)) {
|
||||
return `${GUEST_OPENCLAW_HOME}${path.slice(openclawHostDir.length)}`
|
||||
}
|
||||
return hostPathToGuest(path)
|
||||
}
|
||||
}
|
||||
|
||||
function imageMatchesExpectedRef(
|
||||
actual: string | null,
|
||||
expected: string,
|
||||
): boolean {
|
||||
return (
|
||||
actual === expected || actual?.startsWith(`${expected}@sha256:`) === true
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Helpers used by the `/claw/files/:id/preview` and
|
||||
* `/claw/files/:id/download` routes:
|
||||
*
|
||||
* - MIME-type detection (extension first, magic-byte fallback for
|
||||
* ambiguous extensions).
|
||||
* - Bounded text-snippet reader for inline previews.
|
||||
* - Image bytes reader for the rail's thumbnails.
|
||||
*
|
||||
* No streaming code lives here — the download route streams via Hono
|
||||
* directly. This module only handles the small in-memory reads the
|
||||
* preview UX needs.
|
||||
*/
|
||||
|
||||
import { open, stat } from 'node:fs/promises'
|
||||
import { extname } from 'node:path'
|
||||
|
||||
/** Hard cap on the inline text snippet returned by the preview API. */
|
||||
export const TEXT_PREVIEW_MAX_BYTES = 1 * 1024 * 1024 // 1 MB
|
||||
|
||||
/** Hard cap on inline image bytes returned as a base64 data URL. */
|
||||
export const IMAGE_PREVIEW_MAX_BYTES = 4 * 1024 * 1024 // 4 MB
|
||||
|
||||
const MIME_BY_EXTENSION: Record<string, string> = {
|
||||
'.txt': 'text/plain',
|
||||
'.md': 'text/markdown',
|
||||
'.markdown': 'text/markdown',
|
||||
'.json': 'application/json',
|
||||
'.jsonl': 'application/x-ndjson',
|
||||
'.csv': 'text/csv',
|
||||
'.tsv': 'text/tab-separated-values',
|
||||
'.xml': 'application/xml',
|
||||
'.yaml': 'application/yaml',
|
||||
'.yml': 'application/yaml',
|
||||
'.toml': 'application/toml',
|
||||
'.ini': 'text/plain',
|
||||
'.log': 'text/plain',
|
||||
'.html': 'text/html',
|
||||
'.htm': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'text/javascript',
|
||||
'.mjs': 'text/javascript',
|
||||
'.cjs': 'text/javascript',
|
||||
'.ts': 'text/typescript',
|
||||
'.tsx': 'text/typescript',
|
||||
'.jsx': 'text/javascript',
|
||||
'.py': 'text/x-python',
|
||||
'.rb': 'text/x-ruby',
|
||||
'.go': 'text/x-go',
|
||||
'.rs': 'text/x-rust',
|
||||
'.java': 'text/x-java',
|
||||
'.kt': 'text/x-kotlin',
|
||||
'.swift': 'text/x-swift',
|
||||
'.c': 'text/x-c',
|
||||
'.h': 'text/x-c',
|
||||
'.cpp': 'text/x-c++',
|
||||
'.hpp': 'text/x-c++',
|
||||
'.sh': 'application/x-sh',
|
||||
'.zsh': 'application/x-sh',
|
||||
'.bash': 'application/x-sh',
|
||||
'.sql': 'application/sql',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.bmp': 'image/bmp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.heic': 'image/heic',
|
||||
'.heif': 'image/heif',
|
||||
'.pdf': 'application/pdf',
|
||||
'.zip': 'application/zip',
|
||||
'.tar': 'application/x-tar',
|
||||
'.gz': 'application/gzip',
|
||||
'.tgz': 'application/gzip',
|
||||
'.bz2': 'application/x-bzip2',
|
||||
'.7z': 'application/x-7z-compressed',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mov': 'video/quicktime',
|
||||
'.docx':
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.pptx':
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic-byte signatures for cases where the extension is missing or
|
||||
* misleading. Only covers the formats whose preview path differs from
|
||||
* the default binary path (text vs image vs PDF vs other).
|
||||
*/
|
||||
const MAGIC_BYTE_SIGNATURES: Array<{
|
||||
mime: string
|
||||
matches: (head: Uint8Array) => boolean
|
||||
}> = [
|
||||
{
|
||||
mime: 'image/png',
|
||||
matches: (h) =>
|
||||
h[0] === 0x89 &&
|
||||
h[1] === 0x50 &&
|
||||
h[2] === 0x4e &&
|
||||
h[3] === 0x47 &&
|
||||
h[4] === 0x0d &&
|
||||
h[5] === 0x0a,
|
||||
},
|
||||
{
|
||||
mime: 'image/jpeg',
|
||||
matches: (h) => h[0] === 0xff && h[1] === 0xd8 && h[2] === 0xff,
|
||||
},
|
||||
{
|
||||
mime: 'image/gif',
|
||||
matches: (h) =>
|
||||
h[0] === 0x47 && h[1] === 0x49 && h[2] === 0x46 && h[3] === 0x38,
|
||||
},
|
||||
{
|
||||
mime: 'image/webp',
|
||||
matches: (h) =>
|
||||
h[0] === 0x52 &&
|
||||
h[1] === 0x49 &&
|
||||
h[2] === 0x46 &&
|
||||
h[3] === 0x46 &&
|
||||
h[8] === 0x57 &&
|
||||
h[9] === 0x45 &&
|
||||
h[10] === 0x42 &&
|
||||
h[11] === 0x50,
|
||||
},
|
||||
{
|
||||
mime: 'application/pdf',
|
||||
matches: (h) =>
|
||||
h[0] === 0x25 && h[1] === 0x50 && h[2] === 0x44 && h[3] === 0x46,
|
||||
},
|
||||
]
|
||||
|
||||
const MAGIC_BYTE_PROBE_LEN = 12
|
||||
|
||||
/**
|
||||
* Best-effort MIME detection. Tries the extension map first, then
|
||||
* falls back to magic-byte sniffing for the formats whose preview
|
||||
* path differs from the default binary handling. Returns
|
||||
* `application/octet-stream` when we can't tell.
|
||||
*/
|
||||
export async function detectMimeType(absolutePath: string): Promise<string> {
|
||||
const fromExtension = MIME_BY_EXTENSION[extname(absolutePath).toLowerCase()]
|
||||
if (fromExtension) return fromExtension
|
||||
|
||||
let head: Uint8Array
|
||||
try {
|
||||
const handle = await open(absolutePath, 'r')
|
||||
try {
|
||||
const buffer = new Uint8Array(MAGIC_BYTE_PROBE_LEN)
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
0,
|
||||
MAGIC_BYTE_PROBE_LEN,
|
||||
0,
|
||||
)
|
||||
head = buffer.subarray(0, bytesRead)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
} catch {
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
for (const sig of MAGIC_BYTE_SIGNATURES) {
|
||||
if (sig.matches(head)) return sig.mime
|
||||
}
|
||||
|
||||
if (looksLikeText(head)) return 'text/plain'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
export type PreviewKind = 'text' | 'image' | 'pdf' | 'binary' | 'missing'
|
||||
|
||||
export interface BasePreview {
|
||||
kind: PreviewKind
|
||||
mimeType: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
export interface TextPreview extends BasePreview {
|
||||
kind: 'text'
|
||||
snippet: string
|
||||
/** True when the on-disk file is larger than `TEXT_PREVIEW_MAX_BYTES`. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export interface ImagePreview extends BasePreview {
|
||||
kind: 'image'
|
||||
/** Base64 data URL (incl. `data:` prefix) suitable for `<img src>`. */
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
export interface PdfPreview extends BasePreview {
|
||||
kind: 'pdf'
|
||||
}
|
||||
|
||||
export interface BinaryPreview extends BasePreview {
|
||||
kind: 'binary'
|
||||
}
|
||||
|
||||
export interface MissingPreview {
|
||||
kind: 'missing'
|
||||
}
|
||||
|
||||
export type FilePreview =
|
||||
| TextPreview
|
||||
| ImagePreview
|
||||
| PdfPreview
|
||||
| BinaryPreview
|
||||
| MissingPreview
|
||||
|
||||
/**
|
||||
* Build a preview payload for the inline-card / rail preview Sheet.
|
||||
* Reads at most `TEXT_PREVIEW_MAX_BYTES` (text) or
|
||||
* `IMAGE_PREVIEW_MAX_BYTES` (image) into memory; everything else
|
||||
* returns a metadata-only `binary` preview and the UI offers a
|
||||
* download instead.
|
||||
*/
|
||||
export async function buildFilePreview(
|
||||
absolutePath: string,
|
||||
): Promise<FilePreview> {
|
||||
let stats: Awaited<ReturnType<typeof stat>>
|
||||
try {
|
||||
stats = await stat(absolutePath)
|
||||
} catch {
|
||||
return { kind: 'missing' }
|
||||
}
|
||||
|
||||
const mimeType = await detectMimeType(absolutePath)
|
||||
const base = {
|
||||
mimeType,
|
||||
size: stats.size,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
} as const
|
||||
|
||||
if (mimeType === 'application/pdf') {
|
||||
return { kind: 'pdf', ...base }
|
||||
}
|
||||
|
||||
if (isTextMime(mimeType)) {
|
||||
return readTextPreview(absolutePath, base)
|
||||
}
|
||||
|
||||
if (isImageMime(mimeType)) {
|
||||
return readImagePreview(absolutePath, base)
|
||||
}
|
||||
|
||||
return { kind: 'binary', ...base }
|
||||
}
|
||||
|
||||
async function readTextPreview(
|
||||
absolutePath: string,
|
||||
base: { mimeType: string; size: number; mtimeMs: number },
|
||||
): Promise<TextPreview> {
|
||||
const handle = await open(absolutePath, 'r')
|
||||
try {
|
||||
const length = Math.min(base.size, TEXT_PREVIEW_MAX_BYTES)
|
||||
const buffer = new Uint8Array(length)
|
||||
const { bytesRead } = await handle.read(buffer, 0, length, 0)
|
||||
const snippet = new TextDecoder('utf-8', { fatal: false }).decode(
|
||||
buffer.subarray(0, bytesRead),
|
||||
)
|
||||
return {
|
||||
kind: 'text',
|
||||
...base,
|
||||
snippet,
|
||||
truncated: base.size > TEXT_PREVIEW_MAX_BYTES,
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function readImagePreview(
|
||||
absolutePath: string,
|
||||
base: { mimeType: string; size: number; mtimeMs: number },
|
||||
): Promise<ImagePreview | BinaryPreview> {
|
||||
if (base.size > IMAGE_PREVIEW_MAX_BYTES) {
|
||||
// Too big to inline — let the user download.
|
||||
return { kind: 'binary', ...base }
|
||||
}
|
||||
const handle = await open(absolutePath, 'r')
|
||||
try {
|
||||
const buffer = new Uint8Array(base.size)
|
||||
await handle.read(buffer, 0, base.size, 0)
|
||||
const dataUrl = `data:${base.mimeType};base64,${Buffer.from(buffer).toString('base64')}`
|
||||
return { kind: 'image', ...base, dataUrl }
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
function isTextMime(mime: string): boolean {
|
||||
if (mime.startsWith('text/')) return true
|
||||
return (
|
||||
mime === 'application/json' ||
|
||||
mime === 'application/x-ndjson' ||
|
||||
mime === 'application/xml' ||
|
||||
mime === 'application/yaml' ||
|
||||
mime === 'application/toml' ||
|
||||
mime === 'application/sql' ||
|
||||
mime === 'application/x-sh'
|
||||
)
|
||||
}
|
||||
|
||||
function isImageMime(mime: string): boolean {
|
||||
return mime.startsWith('image/') && mime !== 'image/svg+xml'
|
||||
// SVG is text — let it go through the text path so users can read
|
||||
// markup, not view a base64 blob.
|
||||
}
|
||||
|
||||
/**
|
||||
* Crude text-vs-binary heuristic for files whose extension and magic
|
||||
* bytes both fail to identify them. Counts NUL bytes — text files
|
||||
* essentially never contain them; binaries usually do.
|
||||
*/
|
||||
function looksLikeText(head: Uint8Array): boolean {
|
||||
if (head.length === 0) return true
|
||||
let nulCount = 0
|
||||
for (const byte of head) {
|
||||
if (byte === 0) nulCount += 1
|
||||
}
|
||||
return nulCount === 0
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Converts an aggregated OpenClaw session history (rich content blocks
|
||||
* across the agent's main + sub-sessions) into the flat AgentHistoryPage
|
||||
* shape the chat panel consumes.
|
||||
*
|
||||
* Input: OpenClawSessionHistory.messages — each message has `content`
|
||||
* that is either a string OR an array of typed blocks
|
||||
* ({type: 'text'|'thinking'|'toolCall'|'toolResult'}). The HTTP endpoint
|
||||
* returns the array form even though the type definition says string.
|
||||
*
|
||||
* Output: AgentHistoryEntry[] — flat text per entry, separate `reasoning`
|
||||
* and `toolCalls` fields the UI renders as collapsible sections.
|
||||
*
|
||||
* Tool result pairing: `toolCall` blocks emit on assistant messages;
|
||||
* the matching `toolResult` arrives in a later message (typically with
|
||||
* role 'tool' or 'toolResult'). We pair them by `toolCallId` so the
|
||||
* resulting AgentHistoryToolCall has both input and output.
|
||||
*/
|
||||
|
||||
import { unwrapBrowserosAcpUserMessage } from '../../../lib/agents/acpx-runtime'
|
||||
import type {
|
||||
AgentHistoryEntry,
|
||||
AgentHistoryToolCall,
|
||||
} from '../../../lib/agents/agent-types'
|
||||
import type { AgentHistoryPage } from '../../../lib/agents/types'
|
||||
import type {
|
||||
OpenClawSessionHistory,
|
||||
OpenClawSessionHistoryMessage,
|
||||
} from './openclaw-http-client'
|
||||
|
||||
const CRON_PROMPT_PREFIX_PATTERN =
|
||||
/^\[cron:[0-9a-f-]+ ([^\]]+)\]\s*([\s\S]*?)\n*Current time:[^\n]*(?:\n[\s\S]*)?$/
|
||||
const CRON_DELIVERY_TRAILER =
|
||||
/\n*Use the message tool if you need to notify the user directly[\s\S]*$/
|
||||
const QUEUED_MARKER_LINE =
|
||||
/^\[Queued user message that arrived while the previous turn was still active\]\s*$/m
|
||||
const SUBAGENT_CONTEXT_PREFIX = /^\[Subagent Context\][\s\S]*$/
|
||||
// Emitted by OpenClaw's acp-cli ahead of the BrowserOS envelope. Three
|
||||
// prefix shapes (any combination, in this stack order):
|
||||
//
|
||||
// 1. `[media attached: <internal-path> (<mime>)]` ← per attachment
|
||||
// 2. `[<weekday> <YYYY-MM-DD HH:MM> <TZ>]` ← injectTimestamp
|
||||
// 3. `[Working directory: <path>]` ← acp-cli prefixCwd
|
||||
//
|
||||
// Stacks #1 may appear multiple times (one per image). Stack #2 and #3
|
||||
// can render on the same line separated by a space. Each known prefix is
|
||||
// anchored on its content shape (not just `[…]`) to avoid clobbering
|
||||
// user-typed lines that happen to start with a bracket.
|
||||
const OPENCLAW_MEDIA_PREFIX_LINE = /^\[media attached:[^\]\n]*\]\n/
|
||||
const OPENCLAW_TIMESTAMP_PREFIX =
|
||||
/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) \d{4}-\d{2}-\d{2} \d{2}:\d{2}[^\]\n]*\][ \t]*/
|
||||
const OPENCLAW_WORKDIR_PREFIX = /^\[Working directory: [^\]\n]*\]\n+/
|
||||
|
||||
function stripOpenClawAcpCliEnvelope(value: string): string {
|
||||
let s = value
|
||||
while (OPENCLAW_MEDIA_PREFIX_LINE.test(s)) {
|
||||
s = s.replace(OPENCLAW_MEDIA_PREFIX_LINE, '')
|
||||
}
|
||||
s = s.replace(OPENCLAW_TIMESTAMP_PREFIX, '')
|
||||
s = s.replace(OPENCLAW_WORKDIR_PREFIX, '')
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip OpenClaw + BrowserOS scaffolding from a "user" message before
|
||||
* showing it in the chat panel.
|
||||
*
|
||||
* BrowserOS-side envelope (`<role>…</role>\n\n<user_request>…</user_request>`)
|
||||
* is delegated to `unwrapBrowserosAcpUserMessage`, which performs an
|
||||
* exact-string match against the same constants `buildBrowserosAcpPrompt`
|
||||
* uses to wrap. Matcher and wrapper live in the same repo, so the two
|
||||
* always travel together.
|
||||
*
|
||||
* OpenClaw's acp-cli prepends a `[Working directory: <path>]\n\n` line
|
||||
* before the BrowserOS envelope (see /app/dist/acp-cli-*.js, line 1361).
|
||||
* We strip that single line up-front so the `^<role>` anchor in
|
||||
* `unwrapBrowserosAcpUserMessage` matches.
|
||||
*
|
||||
* OpenClaw-injected scaffolding (cron prefix, queued-marker, subagent
|
||||
* context) is still pattern-matched here. Removing those requires either
|
||||
* an OpenClaw schema change exposing the structured trigger payload, or a
|
||||
* BrowserOS-side side-channel (cache cron payloads on `cron.add` and look
|
||||
* up by jobId). Tracked as the next cleanup; until then this is best-
|
||||
* effort with text-level patterns.
|
||||
*/
|
||||
export function cleanHistoryUserText(raw: string): string {
|
||||
if (!raw) return raw
|
||||
// Queued-marker case: this is structurally a multi-message blob, so
|
||||
// split first and recurse into each chunk. We keep the join character
|
||||
// narrow (single newline) so e.g. five cron payloads render as five
|
||||
// visually-separate lines rather than one wall of text.
|
||||
if (QUEUED_MARKER_LINE.test(raw)) {
|
||||
const chunks = raw
|
||||
.split(
|
||||
/^\[Queued user message that arrived while the previous turn was still active\]\s*$/m,
|
||||
)
|
||||
.map((chunk) => cleanSingleUserMessage(chunk))
|
||||
.filter((chunk) => chunk.length > 0)
|
||||
return chunks.join('\n')
|
||||
}
|
||||
return cleanSingleUserMessage(raw)
|
||||
}
|
||||
|
||||
function cleanSingleUserMessage(raw: string): string {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) return ''
|
||||
// Subagent context seed: pure scaffolding, drop entirely. The real
|
||||
// task lives in the subagent's system prompt; the user-message body
|
||||
// is just framing the model never produced.
|
||||
if (SUBAGENT_CONTEXT_PREFIX.test(trimmed)) {
|
||||
return ''
|
||||
}
|
||||
const cronMatch = CRON_PROMPT_PREFIX_PATTERN.exec(trimmed)
|
||||
if (cronMatch) {
|
||||
const payload = cronMatch[2] ?? ''
|
||||
return payload.replace(CRON_DELIVERY_TRAILER, '').trim()
|
||||
}
|
||||
// Strip OpenClaw's acp-cli envelope (media-attached lines + timestamp
|
||||
// + workdir) before delegating, so the BrowserOS unwrap helper's
|
||||
// `^<role>` anchor matches.
|
||||
const withoutEnvelope = stripOpenClawAcpCliEnvelope(trimmed)
|
||||
return unwrapBrowserosAcpUserMessage(withoutEnvelope).trim()
|
||||
}
|
||||
|
||||
type RichBlock =
|
||||
| { type: 'text'; text?: string }
|
||||
| { type: 'thinking'; thinking?: string; text?: string }
|
||||
| {
|
||||
type: 'toolCall'
|
||||
id?: string
|
||||
toolCallId?: string
|
||||
name?: string
|
||||
arguments?: unknown
|
||||
}
|
||||
| {
|
||||
type: 'toolResult'
|
||||
toolCallId?: string
|
||||
content?: unknown
|
||||
isError?: boolean
|
||||
}
|
||||
| { type: string; [key: string]: unknown }
|
||||
|
||||
// We hold the AgentHistoryToolCall reference itself in `pending` so a
|
||||
// later `toolResult` block mutates the same object that was already
|
||||
// pushed onto the assistant entry's `toolCalls` array.
|
||||
type PendingToolCall = AgentHistoryToolCall
|
||||
|
||||
export function convertOpenClawHistoryToAgentHistory(
|
||||
agentId: string,
|
||||
raw: OpenClawSessionHistory,
|
||||
): AgentHistoryPage {
|
||||
const items: AgentHistoryEntry[] = []
|
||||
// Resolved tool calls keyed by toolCallId — used to attach `output`
|
||||
// back to the assistant entry that issued the call once the tool
|
||||
// result arrives in a subsequent message.
|
||||
const pendingByToolCallId = new Map<string, PendingToolCall>()
|
||||
|
||||
let entryCounter = 0
|
||||
const nextId = () => `${agentId}:hist:${entryCounter++}`
|
||||
|
||||
for (const message of raw.messages) {
|
||||
const blocks = normalizeBlocks(message)
|
||||
const role = normalizeRole(message.role)
|
||||
|
||||
if (!role) {
|
||||
// 'system' / 'tool' messages aren't shown as their own chat entries;
|
||||
// tool results get folded into the assistant entry they complete.
|
||||
if (message.role === 'tool') {
|
||||
applyToolResults(blocks, pendingByToolCallId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const rawText = collectText(blocks).trim()
|
||||
const text = role === 'user' ? cleanHistoryUserText(rawText) : rawText
|
||||
const reasoningText = collectThinking(blocks).trim()
|
||||
const toolCallEntries = collectToolCalls(blocks, pendingByToolCallId)
|
||||
|
||||
// Skip empty entries. Two cases:
|
||||
// - User: cleaner returned empty after stripping scaffolding (e.g.
|
||||
// dropped Subagent Context message). No bubble to render.
|
||||
// - Assistant: model returned only thinking blocks (common with
|
||||
// MiniMax `thinking: minimal` for trivial prompts) and no text
|
||||
// or tools. The empty bubble + dangling reasoning collapsible
|
||||
// reads as broken UI; cleaner to drop the turn entirely.
|
||||
if (!text && toolCallEntries.length === 0) continue
|
||||
|
||||
const entry: AgentHistoryEntry = {
|
||||
id: message.messageId ?? nextId(),
|
||||
agentId,
|
||||
sessionId: 'main',
|
||||
role,
|
||||
text,
|
||||
createdAt: message.timestamp ?? 0,
|
||||
}
|
||||
if (reasoningText) {
|
||||
entry.reasoning = { text: reasoningText }
|
||||
}
|
||||
if (toolCallEntries.length > 0) {
|
||||
entry.toolCalls = toolCallEntries
|
||||
}
|
||||
|
||||
items.push(entry)
|
||||
}
|
||||
|
||||
return {
|
||||
agentId,
|
||||
sessionId: 'main',
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBlocks(message: OpenClawSessionHistoryMessage): RichBlock[] {
|
||||
const content = (message as { content: unknown }).content
|
||||
if (typeof content === 'string') {
|
||||
return content ? [{ type: 'text', text: content }] : []
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content as RichBlock[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function normalizeRole(
|
||||
role: OpenClawSessionHistoryMessage['role'],
|
||||
): 'user' | 'assistant' | null {
|
||||
if (role === 'user' || role === 'assistant') return role
|
||||
return null
|
||||
}
|
||||
|
||||
function collectText(blocks: RichBlock[]): string {
|
||||
const parts: string[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text' && typeof block.text === 'string') {
|
||||
parts.push(block.text)
|
||||
}
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function collectThinking(blocks: RichBlock[]): string {
|
||||
const parts: string[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'thinking') {
|
||||
const value =
|
||||
typeof block.thinking === 'string'
|
||||
? block.thinking
|
||||
: typeof block.text === 'string'
|
||||
? block.text
|
||||
: ''
|
||||
if (value) parts.push(value)
|
||||
}
|
||||
}
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
function collectToolCalls(
|
||||
blocks: RichBlock[],
|
||||
pending: Map<string, PendingToolCall>,
|
||||
): AgentHistoryToolCall[] {
|
||||
const out: AgentHistoryToolCall[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.type !== 'toolCall') continue
|
||||
const callId =
|
||||
typeof block.toolCallId === 'string'
|
||||
? block.toolCallId
|
||||
: typeof block.id === 'string'
|
||||
? block.id
|
||||
: undefined
|
||||
if (!callId) continue
|
||||
const toolName = typeof block.name === 'string' ? block.name : 'unknown'
|
||||
const entry: AgentHistoryToolCall = {
|
||||
toolCallId: callId,
|
||||
toolName,
|
||||
status: 'completed',
|
||||
input: block.arguments,
|
||||
}
|
||||
out.push(entry)
|
||||
// Hold the same reference so a later toolResult mutates the entry
|
||||
// already pushed onto the assistant's toolCalls array.
|
||||
pending.set(callId, entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function applyToolResults(
|
||||
blocks: RichBlock[],
|
||||
pending: Map<string, PendingToolCall>,
|
||||
): void {
|
||||
for (const block of blocks) {
|
||||
if (block.type !== 'toolResult') continue
|
||||
const callId =
|
||||
typeof block.toolCallId === 'string' ? block.toolCallId : undefined
|
||||
if (!callId) continue
|
||||
const entry = pending.get(callId)
|
||||
if (!entry) continue
|
||||
if (block.isError) {
|
||||
entry.status = 'failed'
|
||||
entry.error =
|
||||
typeof block.content === 'string'
|
||||
? block.content
|
||||
: JSON.stringify(block.content)
|
||||
} else {
|
||||
entry.output = block.content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,40 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { join } from 'node:path'
|
||||
import { join, relative, resolve, sep } from 'node:path'
|
||||
|
||||
const STATE_DIR_NAME = '.openclaw'
|
||||
|
||||
/**
|
||||
* Path-traversal guard for `agent.name` before it gets joined into
|
||||
* the host workspace directory. The name is user-supplied at
|
||||
* agent-create time, and `path.join` happily resolves `..` /
|
||||
* absolute segments — so a name like `../../tmp` would point the
|
||||
* workspace at the user's home directory, the harness's pre-turn
|
||||
* snapshot would walk it, and `produced_files` rows would point at
|
||||
* arbitrary host paths that subsequent download / preview routes
|
||||
* would then serve as "agent outputs".
|
||||
*
|
||||
* Reject anything that isn't a flat, single-segment name composed
|
||||
* of safe filename characters. The check is intentionally
|
||||
* conservative — agent names are short slugs in practice.
|
||||
*/
|
||||
export function isAgentWorkspaceNameSafe(name: string): boolean {
|
||||
if (typeof name !== 'string') return false
|
||||
const trimmed = name.trim()
|
||||
if (trimmed === '' || trimmed === '.' || trimmed === '..') return false
|
||||
// No path separators, no NULs, no control chars (charCode < 0x20).
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
const code = trimmed.charCodeAt(i)
|
||||
if (code < 0x20) return false
|
||||
}
|
||||
if (/[\\/]/.test(trimmed)) return false
|
||||
// No `..` segments and no leading dot (avoid hidden / dotfile escapes).
|
||||
if (trimmed.startsWith('.')) return false
|
||||
if (trimmed.includes('..')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function getOpenClawStateDir(openclawDir: string): string {
|
||||
return join(openclawDir, STATE_DIR_NAME)
|
||||
}
|
||||
@@ -24,10 +54,27 @@ export function getHostWorkspaceDir(
|
||||
openclawDir: string,
|
||||
agentName: string,
|
||||
): string {
|
||||
return join(
|
||||
getOpenClawStateDir(openclawDir),
|
||||
if (agentName !== 'main' && !isAgentWorkspaceNameSafe(agentName)) {
|
||||
throw new Error(
|
||||
`Refusing to compute workspace dir for unsafe agent name: ${agentName}`,
|
||||
)
|
||||
}
|
||||
const stateDir = getOpenClawStateDir(openclawDir)
|
||||
const candidate = resolve(
|
||||
stateDir,
|
||||
agentName === 'main' ? 'workspace' : `workspace-${agentName}`,
|
||||
)
|
||||
// Defensive containment check: even with a safe-looking name the
|
||||
// resolved path must live under the state dir. If it doesn't,
|
||||
// refuse rather than return a path the caller would then trust.
|
||||
const stateDirResolved = resolve(stateDir)
|
||||
const rel = relative(stateDirResolved, candidate)
|
||||
if (rel === '' || rel.startsWith('..') || rel.startsWith(`..${sep}`)) {
|
||||
throw new Error(
|
||||
`Resolved workspace dir escapes openclaw state dir: ${candidate}`,
|
||||
)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
export function mergeEnvContent(
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Minimal OpenAI-compatible chat client against the OpenClaw gateway.
|
||||
* Used exclusively by the harness's image carve-out: when the user
|
||||
* attaches images to an OpenClaw agent, the harness diverts the turn
|
||||
* here instead of through the ACP bridge (which silently drops image
|
||||
* content blocks). The gateway's `/v1/chat/completions` endpoint
|
||||
* accepts OpenAI-style multimodal `image_url` parts.
|
||||
*
|
||||
* Output is normalized to `AgentStreamEvent` so the rest of the harness
|
||||
* pipeline (UI streaming, history persistence) doesn't care that the
|
||||
* transport is HTTP rather than ACP for this turn.
|
||||
*/
|
||||
|
||||
import type { AgentStreamEvent } from '../../../lib/agents/types'
|
||||
import { logger } from '../../../lib/logger'
|
||||
|
||||
export type OpenAIContentPart =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image_url'; image_url: { url: string } }
|
||||
|
||||
export interface OpenAIChatMessage {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string | OpenAIContentPart[]
|
||||
}
|
||||
|
||||
export interface GatewayChatTurnInput {
|
||||
/** Gateway-side agent name. Equal to the harness id post Step 9 backfill. */
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
messages: OpenAIChatMessage[]
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export class OpenClawGatewayChatClient {
|
||||
constructor(
|
||||
private readonly getHostPort: () => number,
|
||||
private readonly getToken: () => Promise<string>,
|
||||
) {}
|
||||
|
||||
async streamTurn(
|
||||
input: GatewayChatTurnInput,
|
||||
): Promise<ReadableStream<AgentStreamEvent>> {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${this.getHostPort()}/v1/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: resolveAgentModel(input.agentId),
|
||||
stream: true,
|
||||
messages: input.messages,
|
||||
user: `browseros:${input.agentId}:${input.sessionKey}`,
|
||||
}),
|
||||
signal: input.signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(
|
||||
detail || `OpenClaw gateway chat failed with status ${response.status}`,
|
||||
)
|
||||
}
|
||||
const body = response.body
|
||||
if (!body) {
|
||||
throw new Error('OpenClaw gateway chat response had no body')
|
||||
}
|
||||
|
||||
return new ReadableStream<AgentStreamEvent>({
|
||||
start(controller) {
|
||||
void pumpOpenAIChunks(body, controller, input.signal)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAgentModel(agentId: string): string {
|
||||
// The gateway routes `openclaw` → its default `main` provider config,
|
||||
// and `openclaw/<agentId>` → the per-agent provider config. Backfilled
|
||||
// legacy agents (`main`, orphans) can use the unprefixed form.
|
||||
return agentId === 'main' ? 'openclaw' : `openclaw/${agentId}`
|
||||
}
|
||||
|
||||
async function pumpOpenAIChunks(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
controller: ReadableStreamDefaultController<AgentStreamEvent>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let closed = false
|
||||
let aborted = false
|
||||
let stopReason: string | undefined
|
||||
// Re-emit explicit signal aborts as a clean cancel rather than letting
|
||||
// the underlying `reader.read()` reject — keeps the controller in a
|
||||
// sensible state if the caller bails (e.g. tab close).
|
||||
const onAbort = () => {
|
||||
aborted = true
|
||||
void reader.cancel().catch(() => {})
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
const flushLine = (line: string) => {
|
||||
if (closed || !line.startsWith('data:')) return
|
||||
const payload = line.slice(5).trim()
|
||||
if (!payload || payload === '[DONE]') {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(payload)
|
||||
} catch {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
message: 'Failed to parse OpenClaw gateway chunk',
|
||||
})
|
||||
finish()
|
||||
return
|
||||
}
|
||||
const text = extractDeltaText(parsed)
|
||||
if (text) {
|
||||
controller.enqueue({
|
||||
type: 'text_delta',
|
||||
text,
|
||||
stream: 'output',
|
||||
rawType: 'agent_message_chunk',
|
||||
})
|
||||
}
|
||||
const finishReason = extractFinishReason(parsed)
|
||||
if (finishReason) {
|
||||
stopReason = finishReason === 'stop' ? 'end_turn' : finishReason
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
controller.enqueue({ type: 'done', stopReason: stopReason ?? 'end_turn' })
|
||||
controller.close()
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (aborted) {
|
||||
if (!closed) {
|
||||
closed = true
|
||||
controller.close()
|
||||
}
|
||||
return
|
||||
}
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
let idx = buffer.indexOf('\n\n')
|
||||
while (idx >= 0) {
|
||||
const event = buffer.slice(0, idx)
|
||||
buffer = buffer.slice(idx + 2)
|
||||
for (const line of event.split('\n')) flushLine(line)
|
||||
if (closed) return
|
||||
idx = buffer.indexOf('\n\n')
|
||||
}
|
||||
}
|
||||
if (!closed) {
|
||||
// Stream ended without an explicit [DONE]. Treat as natural end.
|
||||
finish()
|
||||
}
|
||||
} catch (err) {
|
||||
if (closed || aborted) return
|
||||
logger.warn('OpenClaw gateway chat stream errored', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
closed = true
|
||||
controller.close()
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
interface OpenAIStreamChunk {
|
||||
choices?: Array<{
|
||||
delta?: { content?: unknown }
|
||||
finish_reason?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
function extractDeltaText(value: unknown): string {
|
||||
const chunk = value as OpenAIStreamChunk
|
||||
const content = chunk?.choices?.[0]?.delta?.content
|
||||
return typeof content === 'string' ? content : ''
|
||||
}
|
||||
|
||||
function extractFinishReason(value: unknown): string | null {
|
||||
const chunk = value as OpenAIStreamChunk
|
||||
return chunk?.choices?.[0]?.finish_reason ?? null
|
||||
}
|
||||
@@ -44,6 +44,24 @@ export interface OpenClawSessionHistoryMessage {
|
||||
messageId?: string
|
||||
messageSeq?: number
|
||||
timestamp?: number
|
||||
/**
|
||||
* OpenClaw extension envelope. The gateway records the per-session
|
||||
* monotonic sequence on `__openclaw.seq` rather than the top-level
|
||||
* `messageSeq` field, so cursor logic reads from here. `id` is the
|
||||
* gateway's stable message id.
|
||||
*/
|
||||
__openclaw?: { id?: string; seq?: number }
|
||||
/**
|
||||
* Origin of this message when the response merges multiple sessions.
|
||||
* Absent on single-session responses for backward compatibility.
|
||||
*/
|
||||
source?: 'main' | 'cron' | 'hook' | 'channel' | 'other'
|
||||
/**
|
||||
* The session key this message originated from. Differs from the
|
||||
* top-level `sessionKey` when sub-sessions (e.g. cron runs) are merged
|
||||
* into a parent agent's main-session response.
|
||||
*/
|
||||
subSessionKey?: string
|
||||
}
|
||||
|
||||
export interface OpenClawSessionHistory {
|
||||
@@ -74,10 +92,7 @@ export type OpenClawSessionHistoryEvent =
|
||||
| { type: 'error'; data: { message: string } }
|
||||
|
||||
export class OpenClawHttpClient {
|
||||
constructor(
|
||||
private readonly hostPort: number,
|
||||
private readonly getToken: () => Promise<string>,
|
||||
) {}
|
||||
constructor(private readonly hostPort: number) {}
|
||||
|
||||
async getSessionHistory(
|
||||
sessionKey: string,
|
||||
@@ -103,15 +118,9 @@ export class OpenClawHttpClient {
|
||||
|
||||
async isAuthenticated(): Promise<boolean> {
|
||||
try {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${this.hostPort}/v1/models`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
{ method: 'GET' },
|
||||
)
|
||||
return response.ok
|
||||
} catch {
|
||||
@@ -124,15 +133,11 @@ export class OpenClawHttpClient {
|
||||
input: OpenClawSessionHistoryInput,
|
||||
extraHeaders: Record<string, string>,
|
||||
): Promise<Response> {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${this.hostPort}${buildHistoryPath(sessionKey, input)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...extraHeaders,
|
||||
},
|
||||
headers: extraHeaders,
|
||||
signal: input.signal,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Connects to the OpenClaw gateway's WebSocket control plane and pipes
|
||||
* chat broadcast events into a ClawSession state machine. The observer
|
||||
* is a transport layer only — it handles the WS connection lifecycle
|
||||
* (connect, handshake, reconnect) and delegates all state management
|
||||
* to ClawSession.
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import type { ClawSession } from './claw-session'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Protocol types (subset of OpenClaw gateway protocol v3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROTOCOL_VERSION = 3
|
||||
const HANDSHAKE_REQUEST_ID = 'connect'
|
||||
const RECONNECT_DELAY_MS = 5_000
|
||||
const CONNECT_TIMEOUT_MS = 10_000
|
||||
|
||||
interface RequestFrame {
|
||||
type: 'req'
|
||||
id: string
|
||||
method: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
|
||||
type IncomingFrame =
|
||||
| { type: 'res'; id: string; ok: true; payload?: unknown }
|
||||
| {
|
||||
type: 'res'
|
||||
id: string
|
||||
ok: false
|
||||
error: { code: string; message: string }
|
||||
}
|
||||
| { type: 'event'; event: string; payload?: unknown }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Observer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class OpenClawObserver {
|
||||
private ws: WebSocket | null = null
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private connected = false
|
||||
private closed = false
|
||||
private gatewayUrl: string | null = null
|
||||
private gatewayToken: string | null = null
|
||||
|
||||
constructor(private readonly session: ClawSession) {}
|
||||
|
||||
/** Start observing the gateway at the given URL with the given token. */
|
||||
connect(gatewayUrl: string, token: string): void {
|
||||
this.gatewayUrl = gatewayUrl
|
||||
this.gatewayToken = token
|
||||
this.closed = false
|
||||
this.doConnect()
|
||||
}
|
||||
|
||||
/** Stop observing and close the WebSocket. */
|
||||
disconnect(): void {
|
||||
this.closed = true
|
||||
this.clearReconnect()
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.close()
|
||||
} catch {}
|
||||
this.ws = null
|
||||
}
|
||||
this.connected = false
|
||||
}
|
||||
|
||||
/** Whether the observer has an active WS connection. */
|
||||
isConnected(): boolean {
|
||||
return this.connected
|
||||
}
|
||||
|
||||
// ── Private ─────────────────────────────────────────────────────────
|
||||
|
||||
private doConnect(): void {
|
||||
if (this.closed || !this.gatewayUrl || !this.gatewayToken) return
|
||||
|
||||
const wsUrl = this.gatewayUrl
|
||||
.replace(/^http:\/\//, 'ws://')
|
||||
.replace(/^https:\/\//, 'wss://')
|
||||
|
||||
logger.debug('OpenClaw observer connecting', { url: wsUrl })
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
this.ws = ws
|
||||
|
||||
const connectTimeout = setTimeout(() => {
|
||||
logger.warn('OpenClaw observer handshake timeout')
|
||||
ws.terminate()
|
||||
}, CONNECT_TIMEOUT_MS)
|
||||
|
||||
let handshakeSent = false
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
let frame: IncomingFrame
|
||||
try {
|
||||
frame = JSON.parse(raw.toString('utf8')) as IncomingFrame
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// The gateway sends a connect.challenge event before accepting
|
||||
// the connect request. Send the handshake after receiving it.
|
||||
if (
|
||||
frame.type === 'event' &&
|
||||
frame.event === 'connect.challenge' &&
|
||||
!handshakeSent
|
||||
) {
|
||||
handshakeSent = true
|
||||
const connectReq: RequestFrame = {
|
||||
type: 'req',
|
||||
id: HANDSHAKE_REQUEST_ID,
|
||||
method: 'connect',
|
||||
params: {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: 'openclaw-tui',
|
||||
displayName: 'browseros-observer',
|
||||
version: '1.0.0',
|
||||
platform: 'node',
|
||||
mode: 'ui',
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: ['operator.read'],
|
||||
auth: { token: this.gatewayToken },
|
||||
},
|
||||
}
|
||||
ws.send(JSON.stringify(connectReq))
|
||||
return
|
||||
}
|
||||
|
||||
// Handshake response
|
||||
if (frame.type === 'res' && frame.id === HANDSHAKE_REQUEST_ID) {
|
||||
clearTimeout(connectTimeout)
|
||||
if (frame.ok) {
|
||||
this.connected = true
|
||||
logger.info('OpenClaw observer connected')
|
||||
} else {
|
||||
logger.warn('OpenClaw observer handshake failed', {
|
||||
error: frame.error,
|
||||
})
|
||||
ws.close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Broadcast events (only process after handshake completes)
|
||||
if (frame.type === 'event' && this.connected) {
|
||||
this.handleEvent(frame.event, frame.payload)
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
clearTimeout(connectTimeout)
|
||||
this.connected = false
|
||||
this.ws = null
|
||||
|
||||
// Reset any agents stuck in "working" to "unknown" — we missed
|
||||
// the final/end event because the WS closed mid-task. The
|
||||
// ClawSession will re-infer correct state from JSONL when the
|
||||
// observer reconnects and ensureObserverConnected() re-seeds.
|
||||
for (const [agentId, state] of this.session.getAllStates()) {
|
||||
if (state.status === 'working') {
|
||||
this.session.transition(agentId, 'unknown')
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.closed) {
|
||||
logger.debug('OpenClaw observer disconnected, scheduling reconnect')
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('error', (err) => {
|
||||
clearTimeout(connectTimeout)
|
||||
logger.debug('OpenClaw observer WS error', {
|
||||
message: err.message,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private handleEvent(eventName: string, payload: unknown): void {
|
||||
if (eventName === 'chat') {
|
||||
this.handleChatEvent(payload)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a gateway chat broadcast event and transition the ClawSession
|
||||
* state machine accordingly.
|
||||
*/
|
||||
private handleChatEvent(payload: unknown): void {
|
||||
if (!payload || typeof payload !== 'object') return
|
||||
const p = payload as Record<string, unknown>
|
||||
|
||||
const sessionKey = typeof p.sessionKey === 'string' ? p.sessionKey : null
|
||||
const state = typeof p.state === 'string' ? p.state : null
|
||||
|
||||
if (!sessionKey || !state) return
|
||||
|
||||
const agentId = extractAgentId(sessionKey)
|
||||
if (!agentId) return
|
||||
|
||||
if (state === 'delta' || state === 'streaming') {
|
||||
this.session.transition(agentId, 'working', {
|
||||
sessionKey,
|
||||
currentTool: extractToolName(p),
|
||||
})
|
||||
} else if (state === 'final' || state === 'end') {
|
||||
this.session.transition(agentId, 'idle', { sessionKey })
|
||||
} else if (state === 'error') {
|
||||
const errorMsg =
|
||||
typeof p.errorMessage === 'string'
|
||||
? p.errorMessage
|
||||
: typeof p.error === 'string'
|
||||
? p.error
|
||||
: 'Unknown error'
|
||||
this.session.transition(agentId, 'error', { sessionKey, error: errorMsg })
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.clearReconnect()
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.doConnect()
|
||||
}, RECONNECT_DELAY_MS)
|
||||
}
|
||||
|
||||
private clearReconnect(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract agentId from an OpenClaw session key.
|
||||
* Format: "agent:<agentId>:..." — we take the segment after "agent:".
|
||||
*/
|
||||
function extractAgentId(sessionKey: string): string | null {
|
||||
if (!sessionKey.startsWith('agent:')) return null
|
||||
const colonIdx = sessionKey.indexOf(':', 6)
|
||||
if (colonIdx === -1) return sessionKey.slice(6)
|
||||
return sessionKey.slice(6, colonIdx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to extract a tool name from a chat event payload.
|
||||
*/
|
||||
function extractToolName(payload: Record<string, unknown>): string | null {
|
||||
if (typeof payload.toolName === 'string') return payload.toolName
|
||||
if (typeof payload.tool === 'string') return payload.tool
|
||||
const content = payload.content
|
||||
if (content && typeof content === 'object' && 'name' in content) {
|
||||
const name = (content as Record<string, unknown>).name
|
||||
if (typeof name === 'string') return name
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -17,6 +17,12 @@ import {
|
||||
OPENCLAW_IMAGE,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
|
||||
import {
|
||||
configureOpenClawRuntime,
|
||||
getOpenClawRuntime,
|
||||
type OpenClawContainerRuntime,
|
||||
} from '../../../lib/agents/runtime'
|
||||
import type { AgentStreamEvent } from '../../../lib/agents/types'
|
||||
import { getOpenClawDir } from '../../../lib/browseros-dir'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import { withProcessLock } from '../../../lib/process-lock'
|
||||
@@ -25,11 +31,6 @@ import {
|
||||
type AgentSessionState,
|
||||
ClawSession,
|
||||
} from './claw-session'
|
||||
import type {
|
||||
ContainerRuntime,
|
||||
GatewayContainerSpec,
|
||||
} from './container-runtime'
|
||||
import { buildContainerRuntime } from './container-runtime-factory'
|
||||
import {
|
||||
OpenClawAgentAlreadyExistsError,
|
||||
OpenClawAgentNotFoundError,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
type OpenClawAgentRecord,
|
||||
OpenClawCliClient,
|
||||
type OpenClawConfigBatchEntry,
|
||||
type OpenClawSessionEntry,
|
||||
} from './openclaw-cli-client'
|
||||
import {
|
||||
buildOpenClawCliProviderModelRef,
|
||||
@@ -61,8 +63,8 @@ import {
|
||||
OpenClawHttpClient,
|
||||
type OpenClawSessionHistory,
|
||||
type OpenClawSessionHistoryEvent,
|
||||
type OpenClawSessionHistoryMessage,
|
||||
} from './openclaw-http-client'
|
||||
import { OpenClawObserver } from './openclaw-observer'
|
||||
import {
|
||||
type ResolvedOpenClawProviderConfig,
|
||||
resolveSupportedOpenClawProvider,
|
||||
@@ -234,6 +236,104 @@ function getOpenClawBrowserOSSessionPrefix(agentId: string): string {
|
||||
return `agent:${agentId}:openai-user:browseros:${agentId}:`
|
||||
}
|
||||
|
||||
const MAIN_SESSION_KEY_PATTERN = /^agent:([^:]+):main$/
|
||||
|
||||
/**
|
||||
* Extract the agent id from a main-session key (e.g. `agent:research:main`
|
||||
* → `research`). Returns null when the key isn't a top-level main session,
|
||||
* which signals the caller to use the per-session fetch path.
|
||||
*/
|
||||
function extractAgentIdFromMainSessionKey(sessionKey: string): string | null {
|
||||
const match = MAIN_SESSION_KEY_PATTERN.exec(sessionKey)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a session key by its source. The pattern is `agent:<id>:<kind>:...`;
|
||||
* the third segment identifies how the session was started.
|
||||
*/
|
||||
function parseSessionSource(
|
||||
sessionKey: string,
|
||||
): NonNullable<OpenClawSessionHistoryMessage['source']> {
|
||||
const parts = sessionKey.split(':')
|
||||
if (parts[0] !== 'agent' || parts.length < 3) return 'other'
|
||||
switch (parts[2]) {
|
||||
case 'main':
|
||||
return 'main'
|
||||
case 'cron':
|
||||
return 'cron'
|
||||
case 'hook':
|
||||
return 'hook'
|
||||
case 'channel':
|
||||
return 'channel'
|
||||
default:
|
||||
return 'other'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session monotonic sequence. Gateway encodes it inside the
|
||||
* `__openclaw` extension envelope; the legacy top-level `messageSeq`
|
||||
* field exists in the type but is rarely populated.
|
||||
*/
|
||||
function resolveMessageSeq(msg: OpenClawSessionHistoryMessage): number | null {
|
||||
const fromEnvelope = msg.__openclaw?.seq
|
||||
if (typeof fromEnvelope === 'number' && Number.isFinite(fromEnvelope)) {
|
||||
return fromEnvelope
|
||||
}
|
||||
if (typeof msg.messageSeq === 'number' && Number.isFinite(msg.messageSeq)) {
|
||||
return msg.messageSeq
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable chronological order across sessions. Falls back to seq
|
||||
* when timestamps tie or are missing, preserving intra-session order.
|
||||
*/
|
||||
function compareMessageOrder(
|
||||
a: OpenClawSessionHistoryMessage,
|
||||
b: OpenClawSessionHistoryMessage,
|
||||
): number {
|
||||
const aTs = a.timestamp ?? 0
|
||||
const bTs = b.timestamp ?? 0
|
||||
if (aTs !== bTs) return aTs - bTs
|
||||
return (resolveMessageSeq(a) ?? 0) - (resolveMessageSeq(b) ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compound cursor for the aggregated history endpoint. Maps each
|
||||
* session key to either:
|
||||
* - a `messageSeq` to fetch BEFORE on the next page (more historical),
|
||||
* - or `null` meaning the session is exhausted and should be skipped.
|
||||
*
|
||||
* Encoded as base64url JSON for URL-safe transport in `?cursor=`.
|
||||
*/
|
||||
type CompoundCursor = Record<string, number | null>
|
||||
|
||||
function decodeCompoundCursor(encoded: string | undefined): CompoundCursor {
|
||||
if (!encoded) return {}
|
||||
try {
|
||||
const json = Buffer.from(encoded, 'base64url').toString('utf8')
|
||||
const parsed = JSON.parse(json)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const out: CompoundCursor = {}
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (typeof v === 'number' || v === null) out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
} catch {
|
||||
// Malformed cursors are treated as "first page" — preferable to
|
||||
// erroring out the entire history fetch on a bad client cursor.
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function encodeCompoundCursor(cursor: CompoundCursor): string {
|
||||
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url')
|
||||
}
|
||||
|
||||
export interface AgentOverview {
|
||||
agentId: string
|
||||
status: AgentLiveStatus
|
||||
@@ -254,14 +354,12 @@ export interface DashboardResponse {
|
||||
}
|
||||
|
||||
export class OpenClawService {
|
||||
private runtime: ContainerRuntime
|
||||
private runtime: OpenClawContainerRuntime
|
||||
private cliClient: OpenClawCliClient
|
||||
private bootstrapCliClient: OpenClawCliClient
|
||||
private httpClient: OpenClawHttpClient
|
||||
private openclawDir: string
|
||||
private hostPort = OPENCLAW_GATEWAY_CONTAINER_PORT
|
||||
private token: string
|
||||
private tokenLoaded = false
|
||||
private lastError: string | null = null
|
||||
private browserosServerPort: number
|
||||
private resourcesDir: string | null
|
||||
@@ -272,22 +370,17 @@ export class OpenClawService {
|
||||
private stopLogTail: (() => void) | null = null
|
||||
private lifecycleLock: Promise<void> = Promise.resolve()
|
||||
private clawSession = new ClawSession()
|
||||
private observer = new OpenClawObserver(this.clawSession)
|
||||
|
||||
constructor(config: OpenClawServiceConfig = {}) {
|
||||
this.openclawDir = getOpenClawDir()
|
||||
this.runtime = buildContainerRuntime({
|
||||
this.runtime = ensureOpenClawRuntime({
|
||||
resourcesDir: config.resourcesDir,
|
||||
projectDir: this.openclawDir,
|
||||
browserosRoot: config.browserosDir,
|
||||
browserosDir: config.browserosDir,
|
||||
})
|
||||
this.token = crypto.randomUUID()
|
||||
this.runtime.setHostPort(this.hostPort)
|
||||
this.cliClient = new OpenClawCliClient(this.runtime)
|
||||
this.bootstrapCliClient = this.buildBootstrapCliClient()
|
||||
this.httpClient = new OpenClawHttpClient(
|
||||
this.hostPort,
|
||||
async () => this.token,
|
||||
)
|
||||
this.httpClient = new OpenClawHttpClient(this.hostPort)
|
||||
this.browserosServerPort =
|
||||
config.browserosServerPort ?? DEFAULT_PORTS.server
|
||||
this.resourcesDir = config.resourcesDir ?? null
|
||||
@@ -299,23 +392,17 @@ export class OpenClawService {
|
||||
this.browserosServerPort = config.browserosServerPort
|
||||
}
|
||||
|
||||
let runtimeChanged = false
|
||||
if (
|
||||
config.resourcesDir !== undefined &&
|
||||
config.resourcesDir !== this.resourcesDir
|
||||
) {
|
||||
this.resourcesDir = config.resourcesDir
|
||||
runtimeChanged = true
|
||||
}
|
||||
if (
|
||||
config.browserosDir !== undefined &&
|
||||
config.browserosDir !== this.browserosDir
|
||||
) {
|
||||
this.browserosDir = config.browserosDir
|
||||
runtimeChanged = true
|
||||
}
|
||||
if (runtimeChanged) {
|
||||
this.rebuildRuntimeClients()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,19 +410,6 @@ export class OpenClawService {
|
||||
return this.hostPort
|
||||
}
|
||||
|
||||
/**
|
||||
* Current gateway auth token. The token string is loaded from
|
||||
* `gateway.auth.token` in the persisted openclaw.json during setup,
|
||||
* with a freshly generated UUID as fallback. Exposed so the ACPx
|
||||
* harness can pass it to spawned `openclaw acp` child processes via
|
||||
* the documented `OPENCLAW_GATEWAY_TOKEN` env var (avoids both the
|
||||
* `--token` process-listing leak and reliance on a token-file path
|
||||
* that doesn't exist as a discrete file inside the container).
|
||||
*/
|
||||
getGatewayToken(): string {
|
||||
return this.token
|
||||
}
|
||||
|
||||
/** Subscribe to real-time agent status changes from the ClawSession state machine. */
|
||||
onAgentStatusChange(
|
||||
listener: (agentId: string, state: AgentSessionState) => void,
|
||||
@@ -348,6 +422,70 @@ export class OpenClawService {
|
||||
return this.clawSession.getState(agentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the live-status state machine from a turn lifecycle event the
|
||||
* AgentHarnessService observed. Replaces the previous WS observer
|
||||
* pipeline that re-tapped the same gateway events; the harness already
|
||||
* sees them as ACP `session/update` notifications, so we forward those
|
||||
* here. Caller passes the stream events verbatim.
|
||||
*
|
||||
* `tool_call` and `tool_call_update` populate `currentTool` so the
|
||||
* dashboard SSE keeps its existing payload shape. `done` clears
|
||||
* working state to `idle`; `error` keeps a sticky error badge.
|
||||
*/
|
||||
recordAgentTurnEvent(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
event:
|
||||
| { type: 'turn_started' }
|
||||
| { type: 'turn_event'; event: AgentStreamEvent }
|
||||
| { type: 'turn_ended'; error?: string },
|
||||
): void {
|
||||
if (event.type === 'turn_started') {
|
||||
this.clawSession.transition(agentId, 'working', { sessionKey })
|
||||
return
|
||||
}
|
||||
if (event.type === 'turn_ended') {
|
||||
if (event.error !== undefined) {
|
||||
this.clawSession.transition(agentId, 'error', {
|
||||
sessionKey,
|
||||
error: event.error,
|
||||
})
|
||||
} else {
|
||||
this.clawSession.transition(agentId, 'idle', { sessionKey })
|
||||
}
|
||||
return
|
||||
}
|
||||
const inner = event.event
|
||||
if (inner.type === 'tool_call') {
|
||||
this.clawSession.transition(agentId, 'working', {
|
||||
sessionKey,
|
||||
currentTool: inner.title ?? null,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (inner.type === 'error') {
|
||||
this.clawSession.transition(agentId, 'error', {
|
||||
sessionKey,
|
||||
error: inner.message,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (inner.type === 'done') {
|
||||
this.clawSession.transition(agentId, 'idle', { sessionKey })
|
||||
return
|
||||
}
|
||||
if (inner.type === 'text_delta') {
|
||||
// Heartbeat — keep the existing `working` row fresh; preserve
|
||||
// the last-known currentTool by passing it through.
|
||||
const prev = this.clawSession.getState(agentId)
|
||||
this.clawSession.transition(agentId, 'working', {
|
||||
sessionKey,
|
||||
currentTool: prev.currentTool,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
/** Warm the VM and gateway image so later setup/start avoids registry work. */
|
||||
@@ -394,14 +532,13 @@ export class OpenClawService {
|
||||
providerKeyCount: Object.keys(provider.envValues).length,
|
||||
})
|
||||
|
||||
await this.refreshGatewayAuthToken()
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
|
||||
logProgress('Bootstrapping OpenClaw config...')
|
||||
await this.bootstrapCliClient.runOnboard({
|
||||
acceptRisk: true,
|
||||
authChoice: 'skip',
|
||||
gatewayAuth: 'token',
|
||||
gatewayAuth: 'none',
|
||||
gatewayBind: 'lan',
|
||||
gatewayPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
installDaemon: false,
|
||||
@@ -418,13 +555,8 @@ export class OpenClawService {
|
||||
logProgress('Validating OpenClaw config...')
|
||||
await this.assertConfigValid(this.bootstrapCliClient)
|
||||
|
||||
await this.refreshGatewayAuthToken()
|
||||
|
||||
logProgress('Starting OpenClaw gateway...')
|
||||
await this.runtime.startGateway(
|
||||
this.buildGatewayRuntimeSpec(),
|
||||
logProgress,
|
||||
)
|
||||
await this.runtime.startGateway(undefined, logProgress)
|
||||
this.startGatewayLogTail()
|
||||
logProgress('Waiting for gateway readiness...')
|
||||
const ready = await this.runtime.waitForReady(
|
||||
@@ -478,8 +610,6 @@ export class OpenClawService {
|
||||
|
||||
await this.runtime.ensureReady(logProgress)
|
||||
|
||||
logProgress('Refreshing gateway auth token...')
|
||||
await this.refreshGatewayAuthToken()
|
||||
await this.ensureStateEnvFile()
|
||||
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
@@ -504,10 +634,7 @@ export class OpenClawService {
|
||||
}
|
||||
|
||||
logProgress('Starting OpenClaw gateway...')
|
||||
await this.runtime.startGateway(
|
||||
this.buildGatewayRuntimeSpec(),
|
||||
logProgress,
|
||||
)
|
||||
await this.runtime.startGateway(undefined, logProgress)
|
||||
this.startGatewayLogTail()
|
||||
|
||||
logProgress('Waiting for gateway readiness...')
|
||||
@@ -533,7 +660,6 @@ export class OpenClawService {
|
||||
return this.withLifecycleLock('stop', async () => {
|
||||
logger.info('Stopping OpenClaw service', { hostPort: this.hostPort })
|
||||
this.controlPlaneStatus = 'disconnected'
|
||||
this.observer.disconnect()
|
||||
this.stopGatewayLogTail()
|
||||
await this.runtime.stopGateway()
|
||||
logger.info('OpenClaw container stopped')
|
||||
@@ -550,15 +676,10 @@ export class OpenClawService {
|
||||
this.controlPlaneStatus = 'reconnecting'
|
||||
await this.runtime.ensureReady(logProgress)
|
||||
this.stopGatewayLogTail()
|
||||
logProgress('Refreshing gateway auth token...')
|
||||
await this.refreshGatewayAuthToken()
|
||||
await this.ensureStateEnvFile()
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
logProgress('Restarting OpenClaw gateway...')
|
||||
await this.runtime.restartGateway(
|
||||
this.buildGatewayRuntimeSpec(),
|
||||
logProgress,
|
||||
)
|
||||
await this.runtime.restartGateway(undefined, logProgress)
|
||||
this.startGatewayLogTail()
|
||||
|
||||
logProgress('Waiting for gateway readiness...')
|
||||
@@ -588,7 +709,7 @@ export class OpenClawService {
|
||||
})
|
||||
|
||||
logProgress('Checking gateway readiness...')
|
||||
const ready = await this.runtime.isReady(this.hostPort)
|
||||
const ready = await this.runtime.isReady()
|
||||
if (!ready) {
|
||||
this.controlPlaneStatus = 'failed'
|
||||
this.lastGatewayError = 'OpenClaw gateway is not ready'
|
||||
@@ -596,8 +717,6 @@ export class OpenClawService {
|
||||
throw new Error('OpenClaw gateway is not ready')
|
||||
}
|
||||
|
||||
logProgress('Reloading gateway auth token...')
|
||||
await this.refreshGatewayAuthToken()
|
||||
this.controlPlaneStatus = 'reconnecting'
|
||||
logProgress('Reconnecting control plane...')
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
@@ -607,7 +726,6 @@ export class OpenClawService {
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.controlPlaneStatus = 'disconnected'
|
||||
this.observer.disconnect()
|
||||
this.stopGatewayLogTail()
|
||||
try {
|
||||
await this.runtime.stopGateway()
|
||||
@@ -638,9 +756,7 @@ export class OpenClawService {
|
||||
}
|
||||
|
||||
const machineStatus = await this.runtime.getMachineStatus()
|
||||
const ready = machineStatus.running
|
||||
? await this.runtime.isReady(this.hostPort)
|
||||
: false
|
||||
const ready = machineStatus.running ? await this.runtime.isReady() : false
|
||||
|
||||
let agentCount = 0
|
||||
if (ready) {
|
||||
@@ -794,9 +910,155 @@ export class OpenClawService {
|
||||
input: { limit?: number; cursor?: string; signal?: AbortSignal } = {},
|
||||
): Promise<OpenClawSessionHistory> {
|
||||
await this.assertGatewayReady()
|
||||
return this.runControlPlaneCall(() =>
|
||||
this.httpClient.getSessionHistory(sessionKey, input),
|
||||
return this.runControlPlaneCall(async () => {
|
||||
const agentId = extractAgentIdFromMainSessionKey(sessionKey)
|
||||
if (!agentId) {
|
||||
return this.httpClient.getSessionHistory(sessionKey, input)
|
||||
}
|
||||
return this.fetchAggregatedAgentHistory(sessionKey, agentId, input)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates the agent's main session and every sub-session (cron,
|
||||
* hook, channel) into a single chronological response. The main
|
||||
* session's own messages are included; each sub-session's messages
|
||||
* are tagged with `source` and `subSessionKey` so the UI can
|
||||
* distinguish autonomous turns from user-driven turns.
|
||||
*
|
||||
* Pagination uses a compound cursor that encodes a per-session seq
|
||||
* for each session in scope (`{<sessionKey>: seq | null}`). Each page
|
||||
* fetches each non-exhausted session with its own per-session cursor,
|
||||
* merges messages across sessions by timestamp, slices to `limit`,
|
||||
* and emits a fresh compound cursor reflecting where each session
|
||||
* should resume on the next page. A session with `null` in the
|
||||
* cursor is exhausted and skipped.
|
||||
*
|
||||
* Sub-session fetches that fail are logged and dropped — partial
|
||||
* timelines are preferable to a hard failure that hides the main
|
||||
* session.
|
||||
*/
|
||||
private async fetchAggregatedAgentHistory(
|
||||
mainSessionKey: string,
|
||||
agentId: string,
|
||||
input: { limit?: number; cursor?: string; signal?: AbortSignal },
|
||||
): Promise<OpenClawSessionHistory> {
|
||||
const compoundIn = decodeCompoundCursor(input.cursor)
|
||||
const sessions = await this.cliClient
|
||||
.listSessions(agentId)
|
||||
.catch((err): OpenClawSessionEntry[] => {
|
||||
logger.warn(
|
||||
'Failed to list OpenClaw sub-sessions; falling back to main only',
|
||||
{ agentId, error: err instanceof Error ? err.message : String(err) },
|
||||
)
|
||||
return []
|
||||
})
|
||||
|
||||
// Build the candidate set from the agent's session directory plus
|
||||
// the main key (which may not appear in `sessions.list` if the file
|
||||
// hasn't been written yet for a fresh agent).
|
||||
const targetKeys = new Set<string>([mainSessionKey])
|
||||
for (const entry of sessions) {
|
||||
if (entry.key?.startsWith(`agent:${agentId}:`)) {
|
||||
targetKeys.add(entry.key)
|
||||
}
|
||||
}
|
||||
|
||||
// Only fetch sessions that aren't exhausted by the inbound cursor.
|
||||
// A session with `null` in the cursor is fully read; skip it on
|
||||
// subsequent pages.
|
||||
const activeKeys = Array.from(targetKeys).filter(
|
||||
(k) => compoundIn[k] !== null,
|
||||
)
|
||||
|
||||
const fetchedHistories = await Promise.all(
|
||||
activeKeys.map(async (key) => {
|
||||
const sessionCursor = compoundIn[key]
|
||||
try {
|
||||
const history = await this.httpClient.getSessionHistory(key, {
|
||||
limit: input.limit,
|
||||
cursor:
|
||||
typeof sessionCursor === 'number'
|
||||
? String(sessionCursor)
|
||||
: undefined,
|
||||
signal: input.signal,
|
||||
})
|
||||
return { key, history }
|
||||
} catch (err) {
|
||||
logger.warn('Failed to fetch OpenClaw sub-session history', {
|
||||
sessionKey: key,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
type Annotated = OpenClawSessionHistoryMessage & { __sessionKey: string }
|
||||
const merged: Annotated[] = []
|
||||
let truncated = false
|
||||
for (const result of fetchedHistories) {
|
||||
if (!result) continue
|
||||
const source = parseSessionSource(result.key)
|
||||
const isMain = result.key === mainSessionKey
|
||||
for (const msg of result.history.messages) {
|
||||
merged.push({
|
||||
...msg,
|
||||
source,
|
||||
...(isMain ? {} : { subSessionKey: result.key }),
|
||||
__sessionKey: result.key,
|
||||
})
|
||||
}
|
||||
if (result.history.truncated) truncated = true
|
||||
}
|
||||
|
||||
merged.sort(compareMessageOrder)
|
||||
|
||||
// The merged window contains the latest portion fetched. We emit
|
||||
// up to `limit` messages from the END (newest), and compute the
|
||||
// resume position for each session as the seq of the EARLIEST
|
||||
// emitted message that came from that session.
|
||||
const limited =
|
||||
typeof input.limit === 'number' && input.limit > 0
|
||||
? merged.slice(-input.limit)
|
||||
: merged
|
||||
|
||||
const compoundOut: CompoundCursor = {}
|
||||
// Carry forward exhausted sessions so subsequent pages keep skipping them.
|
||||
for (const key of Array.from(targetKeys)) {
|
||||
if (compoundIn[key] === null) {
|
||||
compoundOut[key] = null
|
||||
}
|
||||
}
|
||||
for (const result of fetchedHistories) {
|
||||
if (!result) continue
|
||||
const key = result.key
|
||||
const earliestEmitted = limited.find((m) => m.__sessionKey === key)
|
||||
const sessionFetchHasMore = Boolean(result.history.hasMore)
|
||||
const droppedFromMerge =
|
||||
result.history.messages.length >
|
||||
limited.filter((m) => m.__sessionKey === key).length
|
||||
const sessionHasMore = sessionFetchHasMore || droppedFromMerge
|
||||
if (!sessionHasMore) {
|
||||
compoundOut[key] = null
|
||||
continue
|
||||
}
|
||||
const seq = earliestEmitted ? resolveMessageSeq(earliestEmitted) : null
|
||||
compoundOut[key] = seq
|
||||
}
|
||||
|
||||
const hasMore = Object.values(compoundOut).some(
|
||||
(v) => typeof v === 'number',
|
||||
)
|
||||
const messages = limited.map(({ __sessionKey: _drop, ...rest }) => rest)
|
||||
|
||||
return {
|
||||
sessionKey: mainSessionKey,
|
||||
messages,
|
||||
cursor: hasMore ? encodeCompoundCursor(compoundOut) : null,
|
||||
hasMore,
|
||||
truncated: truncated || limited.length < merged.length,
|
||||
}
|
||||
}
|
||||
|
||||
async streamSessionHistory(
|
||||
@@ -871,7 +1133,6 @@ export class OpenClawService {
|
||||
try {
|
||||
await this.runtime.ensureReady()
|
||||
|
||||
await this.refreshGatewayAuthToken()
|
||||
await this.ensureStateEnvFile()
|
||||
|
||||
const persistedPort = await readPersistedGatewayPort(this.openclawDir)
|
||||
@@ -881,7 +1142,7 @@ export class OpenClawService {
|
||||
|
||||
if (!(await this.isCurrentGatewayAvailable(this.hostPort))) {
|
||||
await this.ensureGatewayPortAllocated()
|
||||
await this.runtime.startGateway(this.buildGatewayRuntimeSpec())
|
||||
await this.runtime.startGateway(undefined)
|
||||
const ready = await this.runtime.waitForReady(
|
||||
this.hostPort,
|
||||
READY_TIMEOUT_MS,
|
||||
@@ -979,32 +1240,18 @@ export class OpenClawService {
|
||||
private buildBootstrapCliClient(): OpenClawCliClient {
|
||||
return new OpenClawCliClient({
|
||||
execInContainer: (command, onLog) =>
|
||||
this.runtime.runGatewaySetupCommand(
|
||||
command,
|
||||
this.buildGatewayRuntimeSpec(),
|
||||
onLog,
|
||||
),
|
||||
this.runtime.runGatewaySetupCommand(command, undefined, onLog),
|
||||
})
|
||||
}
|
||||
|
||||
private rebuildRuntimeClients(): void {
|
||||
this.stopGatewayLogTail()
|
||||
this.runtime = buildContainerRuntime({
|
||||
resourcesDir: this.resourcesDir ?? undefined,
|
||||
projectDir: this.openclawDir,
|
||||
browserosRoot: this.browserosDir,
|
||||
})
|
||||
this.cliClient = new OpenClawCliClient(this.runtime)
|
||||
this.bootstrapCliClient = this.buildBootstrapCliClient()
|
||||
}
|
||||
|
||||
private setPort(hostPort: number): void {
|
||||
if (hostPort === this.hostPort) return
|
||||
this.hostPort = hostPort
|
||||
this.httpClient = new OpenClawHttpClient(
|
||||
this.hostPort,
|
||||
async () => this.token,
|
||||
)
|
||||
// Tests sometimes overwrite this.runtime with a partial mock that
|
||||
// doesn't carry every method — guard so we don't crash when the
|
||||
// mock omits setHostPort.
|
||||
this.runtime.setHostPort?.(hostPort)
|
||||
this.httpClient = new OpenClawHttpClient(this.hostPort)
|
||||
}
|
||||
|
||||
private async ensureGatewayPortAllocated(
|
||||
@@ -1037,25 +1284,13 @@ export class OpenClawService {
|
||||
}
|
||||
|
||||
private async isGatewayAuthenticated(hostPort: number): Promise<boolean> {
|
||||
if (!this.tokenLoaded) {
|
||||
logger.debug(
|
||||
'OpenClaw gateway port is ready before auth token is loaded',
|
||||
{
|
||||
hostPort,
|
||||
},
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const client =
|
||||
hostPort === this.hostPort
|
||||
? this.httpClient
|
||||
: new OpenClawHttpClient(hostPort, async () => this.token)
|
||||
: new OpenClawHttpClient(hostPort)
|
||||
const authenticated = await client.isAuthenticated()
|
||||
if (!authenticated) {
|
||||
logger.warn('OpenClaw gateway port rejected current auth token', {
|
||||
hostPort,
|
||||
})
|
||||
logger.warn('OpenClaw gateway readiness probe failed', { hostPort })
|
||||
}
|
||||
return authenticated
|
||||
}
|
||||
@@ -1066,19 +1301,21 @@ export class OpenClawService {
|
||||
}
|
||||
|
||||
private async isGatewayPortReady(hostPort: number): Promise<boolean> {
|
||||
if (await this.runtime.isReady(hostPort)) return true
|
||||
|
||||
const runtime = this.runtime as {
|
||||
isHealthy?: (port: number) => Promise<boolean>
|
||||
// Route through the runtime's probe when the port matches its
|
||||
// configured one — preserves the no-direct-fetch semantics the
|
||||
// legacy adapter exposed (and that several tests rely on by
|
||||
// mocking runtime.isReady but not the HTTP layer).
|
||||
if (hostPort === this.hostPort) {
|
||||
if (await this.runtime.isReady()) return true
|
||||
const r = this.runtime as { isHealthy?: () => Promise<boolean> }
|
||||
return r.isHealthy ? r.isHealthy() : false
|
||||
}
|
||||
if (runtime.isHealthy) {
|
||||
return runtime.isHealthy(hostPort)
|
||||
}
|
||||
return false
|
||||
if (await fetchOk(`http://127.0.0.1:${hostPort}/readyz`)) return true
|
||||
return fetchOk(`http://127.0.0.1:${hostPort}/healthz`)
|
||||
}
|
||||
|
||||
private async assertGatewayReady(): Promise<void> {
|
||||
const portReady = await this.runtime.isReady(this.hostPort)
|
||||
const portReady = await this.runtime.isReady()
|
||||
logger.debug('Checking OpenClaw gateway readiness before use', {
|
||||
hostPort: this.hostPort,
|
||||
portReady,
|
||||
@@ -1096,12 +1333,10 @@ export class OpenClawService {
|
||||
|
||||
private async runControlPlaneCall<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
await this.ensureTokenLoaded()
|
||||
const result = await fn()
|
||||
this.controlPlaneStatus = 'connected'
|
||||
this.lastGatewayError = null
|
||||
this.lastRecoveryReason = null
|
||||
this.ensureObserverConnected()
|
||||
return result
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
@@ -1113,20 +1348,10 @@ export class OpenClawService {
|
||||
}
|
||||
}
|
||||
|
||||
private ensureObserverConnected(): void {
|
||||
if (this.observer.isConnected()) return
|
||||
// ClawSession starts empty after the JSONL seed was removed; the WS
|
||||
// observer fills in agent status as events arrive.
|
||||
const url = `http://127.0.0.1:${this.hostPort}`
|
||||
this.observer.connect(url, this.token)
|
||||
}
|
||||
|
||||
private classifyControlPlaneError(
|
||||
error: unknown,
|
||||
): OpenClawGatewayRecoveryReason {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (message.includes('Unauthorized')) return 'token_mismatch'
|
||||
if (message.includes('token')) return 'token_mismatch'
|
||||
if (message.includes('not ready')) return 'container_not_ready'
|
||||
return 'unknown'
|
||||
}
|
||||
@@ -1349,16 +1574,6 @@ export class OpenClawService {
|
||||
await writeFile(envPath, '', { mode: 0o600 })
|
||||
}
|
||||
|
||||
private buildGatewayRuntimeSpec(): GatewayContainerSpec {
|
||||
return {
|
||||
hostPort: this.hostPort,
|
||||
hostHome: this.openclawDir,
|
||||
envFilePath: this.getStateEnvPath(),
|
||||
gatewayToken: this.tokenLoaded ? this.token : undefined,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
}
|
||||
}
|
||||
|
||||
private async writeStateEnv(
|
||||
values: Record<string, string>,
|
||||
): Promise<boolean> {
|
||||
@@ -1459,50 +1674,6 @@ export class OpenClawService {
|
||||
return true
|
||||
}
|
||||
|
||||
private async ensureTokenLoaded(): Promise<void> {
|
||||
if (this.tokenLoaded) {
|
||||
return
|
||||
}
|
||||
if (!existsSync(this.getStateConfigPath())) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.loadTokenFromConfig()
|
||||
}
|
||||
|
||||
private async refreshGatewayAuthToken(): Promise<void> {
|
||||
this.tokenLoaded = false
|
||||
if (!existsSync(this.getStateConfigPath())) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.loadTokenFromConfig()
|
||||
}
|
||||
|
||||
private async loadTokenFromConfig(): Promise<void> {
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
await readFile(this.getStateConfigPath(), 'utf-8'),
|
||||
) as {
|
||||
gateway?: {
|
||||
auth?: {
|
||||
token?: unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
const token = config.gateway?.auth?.token
|
||||
if (typeof token === 'string' && token) {
|
||||
this.token = token
|
||||
this.tokenLoaded = true
|
||||
logger.info('Loaded OpenClaw gateway token from mounted config')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to load OpenClaw gateway token from mounted config', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private createProgressLogger(
|
||||
onLog?: (msg: string) => void,
|
||||
): (msg: string) => void {
|
||||
@@ -1562,3 +1733,23 @@ export function getOpenClawService(): OpenClawService {
|
||||
if (!service) service = new OpenClawService()
|
||||
return service
|
||||
}
|
||||
|
||||
async function fetchOk(url: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the OpenClawContainerRuntime, registering it lazily if
|
||||
* main.ts didn't already do so (e.g. tests that build the service
|
||||
* directly). Always succeeds — the runtime constructs on every
|
||||
* platform; lifecycle calls fail at limactl-not-found on non-darwin. */
|
||||
function ensureOpenClawRuntime(opts: {
|
||||
resourcesDir?: string
|
||||
browserosDir?: string
|
||||
}): OpenClawContainerRuntime {
|
||||
return getOpenClawRuntime() ?? configureOpenClawRuntime(opts)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* SQLite-backed store for files an OpenClaw agent produced inside its
|
||||
* workspace during a chat turn. The detection model is a per-turn
|
||||
* snapshot diff: take a `(path → size, mtime)` map of the workspace
|
||||
* before the turn starts, re-scan after the SSE `done` event, and
|
||||
* write a row for any new or modified file.
|
||||
*
|
||||
* Adapter-agnostic by design — the watcher is injected with the
|
||||
* agent's workspace dir, so V2 can plug Claude / Codex turn lifecycle
|
||||
* into the same store with a different `workspaceDir`.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { realpath, stat } from 'node:fs/promises'
|
||||
import { relative, resolve, sep } from 'node:path'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type BrowserOsDatabase, getDb } from '../../../lib/db'
|
||||
import {
|
||||
agentDefinitions,
|
||||
type NewProducedFileRow,
|
||||
type ProducedFileRow,
|
||||
producedFiles,
|
||||
} from '../../../lib/db/schema'
|
||||
import { walkWorkspace } from './produced-files-walker'
|
||||
|
||||
const TURN_PROMPT_MAX_CHARS = 280
|
||||
|
||||
export interface FileSnapshotEntry {
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
/** A `(workspace-relative path → fs metadata)` snapshot of a workspace. */
|
||||
export type FileSnapshot = Map<string, FileSnapshotEntry>
|
||||
|
||||
export interface FinalizeTurnInput {
|
||||
agentDefinitionId: string
|
||||
sessionKey: string
|
||||
turnId: string
|
||||
/** Raw user prompt; truncated to `TURN_PROMPT_MAX_CHARS` before persist. */
|
||||
turnPrompt: string
|
||||
/** Absolute host path to the agent's workspace directory. */
|
||||
workspaceDir: string
|
||||
/** Snapshot taken before the turn began. */
|
||||
before: FileSnapshot
|
||||
}
|
||||
|
||||
export interface ResolvedFile {
|
||||
row: ProducedFileRow
|
||||
/** Absolute host path; guaranteed to live inside the original workspace. */
|
||||
absolutePath: string
|
||||
}
|
||||
|
||||
export class ProducedFilesStore {
|
||||
private readonly db: BrowserOsDatabase
|
||||
|
||||
constructor(options: { db?: BrowserOsDatabase } = {}) {
|
||||
this.db = options.db ?? getDb()
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the workspace and capture every file's size + mtime. Used to
|
||||
* bracket a chat turn so the post-turn diff knows what changed.
|
||||
*/
|
||||
async snapshotWorkspace(workspaceDir: string): Promise<FileSnapshot> {
|
||||
const snapshot: FileSnapshot = new Map()
|
||||
await walkWorkspace(workspaceDir, (relPath, metadata) => {
|
||||
snapshot.set(relPath, metadata)
|
||||
})
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff the live workspace against `before`, persist rows for any
|
||||
* new or modified file, return the rows so the chat-turn finalizer
|
||||
* can broadcast them on the SSE feed. Re-modifications update the
|
||||
* existing row in place (the `(agentDefinitionId, path)` unique
|
||||
* index makes the upsert deterministic).
|
||||
*/
|
||||
async finalizeTurn(input: FinalizeTurnInput): Promise<ProducedFileRow[]> {
|
||||
const after: FileSnapshot = await this.snapshotWorkspace(input.workspaceDir)
|
||||
const changed: Array<{ relPath: string; entry: FileSnapshotEntry }> = []
|
||||
for (const [relPath, entry] of after) {
|
||||
const previous = input.before.get(relPath)
|
||||
if (
|
||||
!previous ||
|
||||
previous.size !== entry.size ||
|
||||
previous.mtimeMs !== entry.mtimeMs
|
||||
) {
|
||||
changed.push({ relPath, entry })
|
||||
}
|
||||
}
|
||||
if (changed.length === 0) return []
|
||||
|
||||
const now = Date.now()
|
||||
const turnPrompt = truncatePrompt(input.turnPrompt)
|
||||
const rows: ProducedFileRow[] = []
|
||||
for (const { relPath, entry } of changed) {
|
||||
const row: NewProducedFileRow = {
|
||||
id: randomUUID(),
|
||||
agentDefinitionId: input.agentDefinitionId,
|
||||
sessionKey: input.sessionKey,
|
||||
turnId: input.turnId,
|
||||
turnPrompt,
|
||||
path: relPath,
|
||||
size: entry.size,
|
||||
mtimeMs: entry.mtimeMs,
|
||||
createdAt: now,
|
||||
detectedBy: 'diff',
|
||||
}
|
||||
// Upsert on (agent, path) — re-modifications win, no duplicates.
|
||||
const upserted = this.db
|
||||
.insert(producedFiles)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({
|
||||
target: [producedFiles.agentDefinitionId, producedFiles.path],
|
||||
set: {
|
||||
sessionKey: row.sessionKey,
|
||||
turnId: row.turnId,
|
||||
turnPrompt: row.turnPrompt,
|
||||
size: row.size,
|
||||
mtimeMs: row.mtimeMs,
|
||||
createdAt: row.createdAt,
|
||||
detectedBy: row.detectedBy,
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
.all()
|
||||
const persisted = upserted[0] ?? row
|
||||
rows.push(persisted as ProducedFileRow)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Inline-card query — files for a single assistant turn. */
|
||||
async listByTurn(turnId: string): Promise<ProducedFileRow[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(producedFiles)
|
||||
.where(eq(producedFiles.turnId, turnId))
|
||||
.orderBy(desc(producedFiles.createdAt))
|
||||
.all()
|
||||
}
|
||||
|
||||
/**
|
||||
* Outputs-rail query — every file an agent has produced across all
|
||||
* sessions, newest first.
|
||||
*/
|
||||
async listByAgent(
|
||||
agentDefinitionId: string,
|
||||
options: { limit?: number } = {},
|
||||
): Promise<ProducedFileRow[]> {
|
||||
const limit = options.limit ?? 200
|
||||
return this.db
|
||||
.select()
|
||||
.from(producedFiles)
|
||||
.where(eq(producedFiles.agentDefinitionId, agentDefinitionId))
|
||||
.orderBy(desc(producedFiles.createdAt))
|
||||
.limit(limit)
|
||||
.all()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a gateway-side OpenClaw agent name (e.g. `main`,
|
||||
* `chief-01`) to the corresponding `agentDefinitions.id` so file
|
||||
* rows can be FK'd back to the harness record.
|
||||
*
|
||||
* Two shapes exist on disk depending on how the agent was added:
|
||||
*
|
||||
* 1. Reconciled rows from `agentHarnessService.reconcileWithGateway`
|
||||
* use `id == openclawAgentId` directly
|
||||
* (see `agent-harness-service.ts:522`).
|
||||
* 2. BrowserOS-created rows use `id = oc-<uuid>` and store the
|
||||
* openclaw name in the `name` column (`db-agent-store.ts:55-65`).
|
||||
*
|
||||
* Lookup tries shape 1 first (direct id hit), then shape 2 by
|
||||
* `(adapter='openclaw', name)`.
|
||||
*/
|
||||
async resolveAgentDefinitionId(
|
||||
openclawAgentId: string,
|
||||
): Promise<string | null> {
|
||||
const directHit = this.db
|
||||
.select({ id: agentDefinitions.id })
|
||||
.from(agentDefinitions)
|
||||
.where(eq(agentDefinitions.id, openclawAgentId))
|
||||
.limit(1)
|
||||
.all()
|
||||
if (directHit[0]) return directHit[0].id
|
||||
|
||||
const byName = this.db
|
||||
.select({ id: agentDefinitions.id })
|
||||
.from(agentDefinitions)
|
||||
.where(
|
||||
and(
|
||||
eq(agentDefinitions.adapter, 'openclaw'),
|
||||
eq(agentDefinitions.name, openclawAgentId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.all()
|
||||
return byName[0]?.id ?? null
|
||||
}
|
||||
|
||||
/** Single-row lookup; null if the id is unknown. */
|
||||
async findById(id: string): Promise<ProducedFileRow | null> {
|
||||
const rows = this.db
|
||||
.select()
|
||||
.from(producedFiles)
|
||||
.where(eq(producedFiles.id, id))
|
||||
.limit(1)
|
||||
.all()
|
||||
return rows[0] ?? null
|
||||
}
|
||||
|
||||
/** Used by `removeRegisteredModel` and similar admin paths later on. */
|
||||
async deleteByAgent(agentDefinitionId: string): Promise<void> {
|
||||
this.db
|
||||
.delete(producedFiles)
|
||||
.where(eq(producedFiles.agentDefinitionId, agentDefinitionId))
|
||||
.run()
|
||||
}
|
||||
|
||||
/** Useful for hard-resetting a session's files (e.g. workspace clear). */
|
||||
async deleteBySession(sessionKey: string): Promise<void> {
|
||||
this.db
|
||||
.delete(producedFiles)
|
||||
.where(eq(producedFiles.sessionKey, sessionKey))
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a stored file id to an absolute host path, after validating
|
||||
* that the on-disk path still lives inside `workspaceDir`. The HTTP
|
||||
* download / preview routes are the only callers; the workspace dir
|
||||
* is supplied by the openclaw service so this module stays
|
||||
* adapter-agnostic.
|
||||
*/
|
||||
async resolveFilePath(input: {
|
||||
fileId: string
|
||||
workspaceDir: string
|
||||
}): Promise<ResolvedFile | null> {
|
||||
const row = await this.findById(input.fileId)
|
||||
if (!row) return null
|
||||
|
||||
const absolutePath = await resolveSafeWorkspacePath(
|
||||
input.workspaceDir,
|
||||
row.path,
|
||||
)
|
||||
if (!absolutePath) return null
|
||||
return { row, absolutePath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Group a flat list of rows by `turnId`, preserving the latest-first
|
||||
* order on the row level and keeping the most-recent group first.
|
||||
* The Outputs rail uses this shape directly.
|
||||
*/
|
||||
groupByTurn(rows: ProducedFileRow[]): Array<{
|
||||
turnId: string
|
||||
turnPrompt: string
|
||||
createdAt: number
|
||||
files: ProducedFileRow[]
|
||||
}> {
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{
|
||||
turnId: string
|
||||
turnPrompt: string
|
||||
createdAt: number
|
||||
files: ProducedFileRow[]
|
||||
}
|
||||
>()
|
||||
for (const row of rows) {
|
||||
const existing = grouped.get(row.turnId)
|
||||
if (!existing) {
|
||||
grouped.set(row.turnId, {
|
||||
turnId: row.turnId,
|
||||
turnPrompt: row.turnPrompt,
|
||||
// Group's createdAt = its newest file (rows are
|
||||
// already desc-by-createdAt, so the first one wins).
|
||||
createdAt: row.createdAt,
|
||||
files: [row],
|
||||
})
|
||||
continue
|
||||
}
|
||||
existing.files.push(row)
|
||||
if (row.createdAt > existing.createdAt) {
|
||||
existing.createdAt = row.createdAt
|
||||
}
|
||||
}
|
||||
return Array.from(grouped.values()).sort(
|
||||
(a, b) => b.createdAt - a.createdAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function truncatePrompt(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.length <= TURN_PROMPT_MAX_CHARS) return trimmed
|
||||
return `${trimmed.slice(0, TURN_PROMPT_MAX_CHARS - 1)}…`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `workspaceDir + relPath` to an absolute host path, but only
|
||||
* if the resolved real path lives inside the workspace root. Returns
|
||||
* null on:
|
||||
* - lexical traversal (`..` segments escaping the root),
|
||||
* - symlink escape (a file in the workspace pointing outside it),
|
||||
* - missing files,
|
||||
* - any unreadable path component.
|
||||
*
|
||||
* Exported so the unit test can hit it without a sqlite handle.
|
||||
*/
|
||||
export async function resolveSafeWorkspacePath(
|
||||
workspaceDir: string,
|
||||
relPath: string,
|
||||
): Promise<string | null> {
|
||||
// Lexical containment first — fail fast without touching the FS.
|
||||
const workspaceRoot = resolve(workspaceDir)
|
||||
const lexical = resolve(workspaceRoot, relPath)
|
||||
const lexicalRel = relative(workspaceRoot, lexical)
|
||||
if (
|
||||
lexicalRel === '' ||
|
||||
lexicalRel.startsWith('..') ||
|
||||
lexicalRel.startsWith(`..${sep}`)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Realpath check — collapses symlinks so a workspace symlink that
|
||||
// points outside the root cannot be downloaded. Falls through to
|
||||
// null if anything errors (file gone, permissions, broken link).
|
||||
try {
|
||||
const [realRoot, realFile] = await Promise.all([
|
||||
realpath(workspaceRoot),
|
||||
realpath(lexical),
|
||||
])
|
||||
const realRel = relative(realRoot, realFile)
|
||||
if (
|
||||
realRel === '' ||
|
||||
realRel.startsWith('..') ||
|
||||
realRel.startsWith(`..${sep}`)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
await stat(realFile)
|
||||
return realFile
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export the row type so callers pulling the store don't have to
|
||||
// also import the schema module.
|
||||
export type { ProducedFileRow }
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Workspace walker used by the produced-files diff watcher. Recurses
|
||||
* an OpenClaw agent's workspace directory and yields one
|
||||
* `(workspace-relative path, size, mtime)` triple per file.
|
||||
*
|
||||
* Design choices:
|
||||
*
|
||||
* - **Pure async iteration.** No third-party deps; relies on
|
||||
* `fs.promises.readdir` + `Dirent` so directory traversal is one
|
||||
* syscall per directory.
|
||||
* - **Symlink-aware.** Symlinks themselves aren't followed (they
|
||||
* appear in `Dirent.isSymbolicLink()`); the walker skips them so
|
||||
* an agent can't smuggle host-fs paths into the diff via a
|
||||
* symlink in its workspace.
|
||||
* - **Excludes well-known cruft directories** that no useful agent
|
||||
* output ever lives inside (`node_modules`, `.git`, `.cache`).
|
||||
* These directories are also expensive to traverse, so skipping
|
||||
* them keeps the per-turn snapshot fast.
|
||||
* - **Bounded.** Hard caps on entry count and recursion depth keep
|
||||
* pathological workspaces from stalling the chat-turn finalizer.
|
||||
*/
|
||||
|
||||
import type { Dirent } from 'node:fs'
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
|
||||
const EXCLUDED_DIRECTORIES = new Set(['node_modules', '.git', '.cache'])
|
||||
|
||||
const MAX_ENTRIES = 50_000
|
||||
const MAX_DEPTH = 16
|
||||
|
||||
export interface WorkspaceFileMetadata {
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
export type WorkspaceFileVisitor = (
|
||||
/** Workspace-relative path (POSIX-style separators). */
|
||||
relativePath: string,
|
||||
metadata: WorkspaceFileMetadata,
|
||||
) => void
|
||||
|
||||
/**
|
||||
* Walk `workspaceDir` recursively, calling `visit` for every regular
|
||||
* file. Returns silently if the directory doesn't exist (a fresh
|
||||
* agent that hasn't produced anything yet shouldn't error here).
|
||||
*/
|
||||
export async function walkWorkspace(
|
||||
workspaceDir: string,
|
||||
visit: WorkspaceFileVisitor,
|
||||
): Promise<void> {
|
||||
let entriesSeen = 0
|
||||
await walk(workspaceDir, workspaceDir, 0, (file) => {
|
||||
entriesSeen += 1
|
||||
if (entriesSeen > MAX_ENTRIES) return false
|
||||
visit(file.relativePath, file.metadata)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
interface VisitedFile {
|
||||
relativePath: string
|
||||
metadata: WorkspaceFileMetadata
|
||||
}
|
||||
|
||||
async function walk(
|
||||
root: string,
|
||||
current: string,
|
||||
depth: number,
|
||||
yieldFile: (file: VisitedFile) => boolean,
|
||||
): Promise<boolean> {
|
||||
if (depth > MAX_DEPTH) return true
|
||||
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = await readdir(current, { withFileTypes: true })
|
||||
} catch {
|
||||
// Workspace dir missing or unreadable — fresh agent that hasn't
|
||||
// written anything yet, or transient permissions issue. Treat as
|
||||
// "no files" rather than throwing.
|
||||
return true
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (EXCLUDED_DIRECTORIES.has(entry.name)) continue
|
||||
const absolute = join(current, entry.name)
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
// Skip symlinks — never follow, never record. Prevents an
|
||||
// agent from smuggling host-fs paths into the diff via a
|
||||
// symlink in its workspace.
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const keepGoing = await walk(root, absolute, depth + 1, yieldFile)
|
||||
if (!keepGoing) return false
|
||||
continue
|
||||
}
|
||||
|
||||
if (!entry.isFile()) continue
|
||||
|
||||
let stats: Awaited<ReturnType<typeof stat>>
|
||||
try {
|
||||
stats = await stat(absolute)
|
||||
} catch {
|
||||
// Concurrent delete between readdir and stat — skip silently.
|
||||
continue
|
||||
}
|
||||
const relativePath = toPosix(relative(root, absolute))
|
||||
const keepGoing = yieldFile({
|
||||
relativePath,
|
||||
metadata: { size: stats.size, mtimeMs: stats.mtimeMs },
|
||||
})
|
||||
if (!keepGoing) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function toPosix(value: string): string {
|
||||
if (sep === '/') return value
|
||||
return value.split(sep).join('/')
|
||||
}
|
||||
@@ -4,16 +4,13 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { createRuntimeStore } from 'acpx/runtime'
|
||||
import type { OpenClawGatewayChatClient } from '../../api/services/openclaw/openclaw-gateway-chat-client'
|
||||
import type { AgentDefinition } from './agent-types'
|
||||
import { prepareClaudeCodeContext } from './claude-code/prepare'
|
||||
import { prepareCodexContext } from './codex/prepare'
|
||||
import {
|
||||
maybeHandleOpenClawTurn,
|
||||
prepareClaudeCodeContext,
|
||||
prepareCodexContext,
|
||||
prepareHermesContext,
|
||||
prepareOpenClawContext,
|
||||
} from './openclaw/prepare'
|
||||
import type { AgentPromptInput, AgentStreamEvent } from './types'
|
||||
} from './runtime'
|
||||
|
||||
export interface PreparedAcpxAgentContext {
|
||||
cwd: string
|
||||
@@ -22,6 +19,14 @@ export interface PreparedAcpxAgentContext {
|
||||
commandEnv: Record<string, string>
|
||||
commandIdentity: string
|
||||
useBrowserosMcp: boolean
|
||||
/**
|
||||
* Hostname the agent should use to reach the BrowserOS HTTP MCP server.
|
||||
* Default `127.0.0.1` is correct for host-process adapters (claude, codex,
|
||||
* Phase A host-mode hermes). Container-spawned adapters override this to
|
||||
* `host.containers.internal` so the URL injected into ACP newSession's
|
||||
* mcpServers resolves from inside the container.
|
||||
*/
|
||||
browserosMcpHost?: string
|
||||
openclawSessionKey: string | null
|
||||
}
|
||||
|
||||
@@ -35,29 +40,17 @@ export interface PrepareAcpxAgentContextInput {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface AcpxAdapterTurnInput {
|
||||
prompt: AgentPromptInput
|
||||
prepared: PreparedAcpxAgentContext
|
||||
sessionStore: ReturnType<typeof createRuntimeStore>
|
||||
openclawGatewayChat: OpenClawGatewayChatClient | null
|
||||
}
|
||||
|
||||
export interface AcpxAgentAdapter {
|
||||
prepare(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext>
|
||||
maybeHandleTurn?(
|
||||
input: AcpxAdapterTurnInput,
|
||||
): Promise<ReadableStream<AgentStreamEvent> | null>
|
||||
}
|
||||
|
||||
const ADAPTERS: Record<AgentDefinition['adapter'], AcpxAgentAdapter> = {
|
||||
claude: { prepare: prepareClaudeCodeContext },
|
||||
codex: { prepare: prepareCodexContext },
|
||||
openclaw: {
|
||||
prepare: prepareOpenClawContext,
|
||||
maybeHandleTurn: maybeHandleOpenClawTurn,
|
||||
},
|
||||
openclaw: { prepare: prepareOpenClawContext },
|
||||
hermes: { prepare: prepareHermesContext },
|
||||
}
|
||||
|
||||
export function getAcpxAgentAdapter(
|
||||
|
||||
@@ -57,6 +57,7 @@ export async function finishBrowserosManagedContext(input: {
|
||||
skillNames: string[]
|
||||
promptPrefix: string
|
||||
commandEnv: Record<string, string>
|
||||
browserosMcpHost?: string
|
||||
}): Promise<PreparedAcpxAgentContext> {
|
||||
const commandIdentity = stableCommandIdentity(input.commandEnv)
|
||||
const runtimeSessionKey = deriveRuntimeSessionKey({
|
||||
@@ -83,6 +84,7 @@ export async function finishBrowserosManagedContext(input: {
|
||||
commandEnv: input.commandEnv,
|
||||
commandIdentity,
|
||||
useBrowserosMcp: true,
|
||||
browserosMcpHost: input.browserosMcpHost,
|
||||
openclawSessionKey: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -19,13 +18,9 @@ import {
|
||||
createAgentRegistry,
|
||||
createRuntimeStore,
|
||||
} from 'acpx/runtime'
|
||||
import type { OpenClawGatewayChatClient } from '../../api/services/openclaw/openclaw-gateway-chat-client'
|
||||
import { getBrowserosDir } from '../browseros-dir'
|
||||
import { logger } from '../logger'
|
||||
import {
|
||||
getAcpxAgentAdapter,
|
||||
prepareAcpxAgentContext,
|
||||
} from './acpx-agent-adapter'
|
||||
import { prepareAcpxAgentContext } from './acpx-agent-adapter'
|
||||
import {
|
||||
resolveAgentRuntimePaths,
|
||||
wrapCommandWithEnv,
|
||||
@@ -36,6 +31,7 @@ import type {
|
||||
AgentHistoryEntry,
|
||||
AgentHistoryToolCall,
|
||||
} from './agent-types'
|
||||
import { getHermesRuntime, getOpenClawRuntime } from './runtime'
|
||||
import type {
|
||||
AgentHistoryPage,
|
||||
AgentPromptInput,
|
||||
@@ -46,45 +42,11 @@ 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 token and VM/container paths at spawn time.
|
||||
*/
|
||||
export interface OpenclawGatewayAccessor {
|
||||
/** Current gateway auth token. Passed to `openclaw acp --token`. */
|
||||
getGatewayToken(): string
|
||||
/** 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
|
||||
}
|
||||
|
||||
type AcpxRuntimeOptions = {
|
||||
cwd?: string
|
||||
browserosDir?: string
|
||||
stateDir?: string
|
||||
browserosServerPort?: number
|
||||
/**
|
||||
* Required for adapter='openclaw' agents; harmless when absent for
|
||||
* claude/codex (their adapters spawn their own CLI binaries).
|
||||
*/
|
||||
openclawGateway?: OpenclawGatewayAccessor
|
||||
/**
|
||||
* Optional. When wired, the runtime diverts OpenClaw turns that
|
||||
* carry image attachments to the gateway's HTTP `/v1/chat/completions`
|
||||
* endpoint (which accepts OpenAI-style `image_url` parts) instead of
|
||||
* the ACP bridge — the bridge silently drops image content blocks.
|
||||
* Without this client, image turns to OpenClaw agents fall through to
|
||||
* the ACP path and the model never sees the image.
|
||||
*/
|
||||
openclawGatewayChat?: OpenClawGatewayChatClient
|
||||
runtimeFactory?: (options: AcpRuntimeOptions) => AcpxCoreRuntime
|
||||
}
|
||||
|
||||
@@ -95,22 +57,15 @@ interface PreparedRuntimeContext {
|
||||
agentCommandEnv: Record<string, string>
|
||||
commandIdentity: string
|
||||
useBrowserosMcp: boolean
|
||||
browserosMcpHost?: string
|
||||
openclawSessionKey: string | null
|
||||
}
|
||||
|
||||
const BROWSEROS_ACP_AGENT_INSTRUCTIONS = `<role>
|
||||
You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.
|
||||
|
||||
Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.
|
||||
</role>`
|
||||
|
||||
export class AcpxRuntime implements AgentRuntime {
|
||||
private readonly defaultCwd: string | null
|
||||
private readonly browserosDir: string
|
||||
private readonly stateDir: string
|
||||
private readonly browserosServerPort: number
|
||||
private readonly openclawGateway: OpenclawGatewayAccessor | null
|
||||
private readonly openclawGatewayChat: OpenClawGatewayChatClient | null
|
||||
private readonly runtimeFactory: (
|
||||
options: AcpRuntimeOptions,
|
||||
) => AcpxCoreRuntime
|
||||
@@ -126,8 +81,6 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
join(this.browserosDir, 'agents', 'acpx')
|
||||
this.browserosServerPort =
|
||||
options.browserosServerPort ?? DEFAULT_PORTS.server
|
||||
this.openclawGateway = options.openclawGateway ?? null
|
||||
this.openclawGatewayChat = options.openclawGatewayChat ?? null
|
||||
this.sessionStore = createRuntimeStore({ stateDir: this.stateDir })
|
||||
this.runtimeFactory = options.runtimeFactory ?? createAcpRuntime
|
||||
}
|
||||
@@ -205,24 +158,6 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
imageAttachmentCount: imageAttachments.length,
|
||||
})
|
||||
|
||||
const adapter = getAcpxAgentAdapter(input.agent.adapter)
|
||||
const adapterStream =
|
||||
(await adapter.maybeHandleTurn?.({
|
||||
prompt: input,
|
||||
prepared: {
|
||||
cwd: prepared.cwd,
|
||||
runtimeSessionKey: prepared.runtimeSessionKey,
|
||||
runPrompt: prepared.runPrompt,
|
||||
commandEnv: prepared.agentCommandEnv,
|
||||
commandIdentity: prepared.commandIdentity,
|
||||
useBrowserosMcp: prepared.useBrowserosMcp,
|
||||
openclawSessionKey: prepared.openclawSessionKey,
|
||||
},
|
||||
sessionStore: this.sessionStore,
|
||||
openclawGatewayChat: this.openclawGatewayChat,
|
||||
})) ?? null
|
||||
if (adapterStream) return adapterStream
|
||||
|
||||
const runtime = this.getRuntime({
|
||||
cwd,
|
||||
permissionMode: input.permissionMode,
|
||||
@@ -230,6 +165,7 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
commandEnv: prepared.agentCommandEnv,
|
||||
commandIdentity: prepared.commandIdentity,
|
||||
useBrowserosMcp: prepared.useBrowserosMcp,
|
||||
browserosMcpHost: prepared.browserosMcpHost,
|
||||
openclawSessionKey: prepared.openclawSessionKey,
|
||||
})
|
||||
|
||||
@@ -277,6 +213,7 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
agentCommandEnv: prepared.commandEnv,
|
||||
commandIdentity: prepared.commandIdentity,
|
||||
useBrowserosMcp: prepared.useBrowserosMcp,
|
||||
browserosMcpHost: prepared.browserosMcpHost,
|
||||
openclawSessionKey: prepared.openclawSessionKey,
|
||||
}
|
||||
}
|
||||
@@ -288,14 +225,17 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
commandEnv: Record<string, string>
|
||||
commandIdentity: string
|
||||
useBrowserosMcp: boolean
|
||||
browserosMcpHost?: string
|
||||
openclawSessionKey: string | null
|
||||
}): AcpxCoreRuntime {
|
||||
const mcpHost = input.browserosMcpHost ?? '127.0.0.1'
|
||||
const key = JSON.stringify({
|
||||
cwd: input.cwd,
|
||||
permissionMode: input.permissionMode,
|
||||
nonInteractivePermissions: input.nonInteractivePermissions,
|
||||
commandIdentity: input.commandIdentity,
|
||||
useBrowserosMcp: input.useBrowserosMcp,
|
||||
browserosMcpHost: mcpHost,
|
||||
openclawSessionKey: input.openclawSessionKey,
|
||||
})
|
||||
const existing = this.runtimes.get(key)
|
||||
@@ -305,12 +245,11 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
cwd: input.cwd,
|
||||
sessionStore: this.sessionStore,
|
||||
agentRegistry: createBrowserosAgentRegistry({
|
||||
openclawGateway: this.openclawGateway,
|
||||
openclawSessionKey: input.openclawSessionKey,
|
||||
commandEnv: input.commandEnv,
|
||||
}),
|
||||
mcpServers: input.useBrowserosMcp
|
||||
? createBrowserosMcpServers(this.browserosServerPort)
|
||||
? createBrowserosMcpServers(this.browserosServerPort, mcpHost)
|
||||
: [],
|
||||
permissionMode: input.permissionMode,
|
||||
nonInteractivePermissions: input.nonInteractivePermissions,
|
||||
@@ -322,6 +261,7 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
permissionMode: input.permissionMode,
|
||||
nonInteractivePermissions: input.nonInteractivePermissions,
|
||||
browserosServerPort: this.browserosServerPort,
|
||||
browserosMcpHost: mcpHost,
|
||||
commandIdentity: input.commandIdentity,
|
||||
useBrowserosMcp: input.useBrowserosMcp,
|
||||
openclawSessionKey: input.openclawSessionKey,
|
||||
@@ -509,14 +449,16 @@ export function unwrapBrowserosAcpUserMessage(raw: string): string {
|
||||
}
|
||||
|
||||
function stripOuterRoleEnvelope(value: string): string {
|
||||
const prefix = `${BROWSEROS_ACP_AGENT_INSTRUCTIONS}
|
||||
|
||||
<user_request>
|
||||
`
|
||||
const suffix = `
|
||||
</user_request>`
|
||||
if (!value.startsWith(prefix) || !value.endsWith(suffix)) return value
|
||||
return value.slice(prefix.length, -suffix.length)
|
||||
// Any `<role>…</role>\n\n<user_request>\n…\n</user_request>` envelope.
|
||||
// Adapter-agnostic so both the BrowserOS multi-line role block and the
|
||||
// openclaw single-line role block get unwrapped. TKT-774's exact-prefix
|
||||
// match only covered the BrowserOS form, so the openclaw envelope
|
||||
// (added when openclaw moved to its own prepare step) was landing
|
||||
// unwrapped in history payloads.
|
||||
const match = value.match(
|
||||
/^<role\b[^>]*>[\s\S]*?<\/role>\n\n<user_request>\n([\s\S]*?)\n<\/user_request>$/,
|
||||
)
|
||||
return match ? match[1] : value
|
||||
}
|
||||
|
||||
function stripOuterRuntimeEnvelope(value: string): string {
|
||||
@@ -700,19 +642,19 @@ function createAcpxEventStream(
|
||||
|
||||
function createBrowserosMcpServers(
|
||||
browserosServerPort: number,
|
||||
host = '127.0.0.1',
|
||||
): NonNullable<AcpRuntimeOptions['mcpServers']> {
|
||||
return [
|
||||
{
|
||||
type: 'http',
|
||||
name: 'browseros',
|
||||
url: `http://127.0.0.1:${browserosServerPort}/mcp`,
|
||||
url: `http://${host}:${browserosServerPort}/mcp`,
|
||||
headers: [],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function createBrowserosAgentRegistry(input: {
|
||||
openclawGateway: OpenclawGatewayAccessor | null
|
||||
openclawSessionKey: string | null
|
||||
commandEnv: Record<string, string>
|
||||
}): AcpRuntimeOptions['agentRegistry'] {
|
||||
@@ -726,20 +668,38 @@ function createBrowserosAgentRegistry(input: {
|
||||
const lower = agentName.trim().toLowerCase()
|
||||
|
||||
if (lower === 'openclaw') {
|
||||
if (!input.openclawGateway) {
|
||||
// Fall back to acpx's built-in `openclaw` adapter, which assumes
|
||||
// a host-side openclaw binary. BrowserOS doesn't install one on
|
||||
// the host, so this branch will fail at spawn time with a
|
||||
// descriptive error — the harness should be wired with a
|
||||
// gateway accessor.
|
||||
return registry.resolve(agentName)
|
||||
const runtime = getOpenClawRuntime()
|
||||
if (runtime) {
|
||||
return runtime.buildExecArgv(
|
||||
runtime.getAcpExecSpec({
|
||||
commandEnv: input.commandEnv,
|
||||
openclawSessionKey: input.openclawSessionKey,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return resolveOpenclawAcpCommand(
|
||||
input.openclawGateway,
|
||||
input.openclawSessionKey,
|
||||
)
|
||||
// Tests / non-darwin: fall back to acpx-core's built-in
|
||||
// `openclaw` adapter, which assumes a host-side openclaw
|
||||
// binary. BrowserOS doesn't install one on the host, so this
|
||||
// branch fails at spawn time with a descriptive error.
|
||||
return registry.resolve(agentName)
|
||||
}
|
||||
|
||||
if (lower === 'hermes') {
|
||||
const runtime = getHermesRuntime()
|
||||
if (runtime)
|
||||
return runtime.buildExecArgv(runtime.getAcpExecSpec(input.commandEnv))
|
||||
// No runtime registered (tests, dev fallback, non-darwin) →
|
||||
// host-process spawn of the bare hermes binary.
|
||||
return wrapCommandWithEnv('hermes acp', input.commandEnv)
|
||||
}
|
||||
|
||||
// claude + codex resolve through acpx-core's built-in registry
|
||||
// because the canonical command is an npx wrapper around the
|
||||
// upstream ACP-adapter package (e.g. `npx @zed-industries/codex-acp`),
|
||||
// and the package version range lives inside acpx-core. The
|
||||
// ClaudeRuntime / CodexRuntime registrations still drive health
|
||||
// probing and per-turn prep; only the spawn command source-of-
|
||||
// truth stays in acpx-core.
|
||||
if (lower === 'claude' || lower === 'codex') {
|
||||
return wrapCommandWithEnv(registry.resolve(agentName), input.commandEnv)
|
||||
}
|
||||
@@ -749,82 +709,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: `openclaw acp --url ...` deliberately does not reuse implicit
|
||||
* env/config credentials, so pass the gateway token explicitly.
|
||||
*
|
||||
* 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 token = gateway.getGatewayToken()
|
||||
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,
|
||||
'--token',
|
||||
token,
|
||||
]
|
||||
if (bridgeSessionKey) {
|
||||
argv.push('--session', bridgeSessionKey)
|
||||
}
|
||||
return argv.join(' ')
|
||||
}
|
||||
|
||||
async function applyRuntimeControls(
|
||||
runtime: AcpxCoreRuntime,
|
||||
handle: AcpRuntimeHandle,
|
||||
|
||||
@@ -91,7 +91,7 @@ export class RingBuffer {
|
||||
/** Frames with seq > fromSeq, plus the terminal frame if not already in the slice. */
|
||||
slice(fromSeq: number): TurnFrame[] {
|
||||
const live = this.frames.filter((f) => f.seq > fromSeq)
|
||||
if (this.terminal && !live.some((f) => f.seq === this.terminal!.seq)) {
|
||||
if (this.terminal && !live.some((f) => f.seq === this.terminal?.seq)) {
|
||||
// Terminal might have been evicted by overflow; re-attach it so
|
||||
// subscribers always see a terminal if one exists.
|
||||
if (this.terminal.seq > fromSeq) live.push(this.terminal)
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { exec } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { logger } from '../logger'
|
||||
import type { AgentAdapter } from './agent-types'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
import {
|
||||
type AgentRuntime,
|
||||
type AgentRuntimeRegistry,
|
||||
getAgentRuntimeRegistry,
|
||||
HostProcessAgentRuntime,
|
||||
} from './runtime'
|
||||
|
||||
export interface AdapterHealth {
|
||||
healthy: boolean
|
||||
@@ -19,100 +20,48 @@ export interface AdapterHealth {
|
||||
checkedAt: number
|
||||
}
|
||||
|
||||
interface CachedHealth extends AdapterHealth {
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory cache of adapter binary availability. Probed lazily on
|
||||
* first read and refreshed every `cacheTtlMs`. The probe is one
|
||||
* `<binary> --version` invocation per adapter with a hard 2s timeout
|
||||
* so a hung CLI doesn't block the listing endpoint.
|
||||
* Reports adapter readiness for the `/adapters` route. Reads from the
|
||||
* `AgentRuntimeRegistry` — host-process runtimes self-cache their
|
||||
* `<binary> --version` probe; container runtimes expose lifecycle
|
||||
* state via the same snapshot.
|
||||
*
|
||||
* OpenClaw isn't probed here — its health derives from the gateway
|
||||
* lifecycle snapshot already exposed via `getGatewayStatus()`.
|
||||
* OpenClaw still falls back to a permissive default until Phase 4
|
||||
* migrates it onto a runtime — its health currently comes from the
|
||||
* gateway lifecycle snapshot the harness already exposes.
|
||||
*/
|
||||
export class AdapterHealthChecker {
|
||||
private readonly cache = new Map<AgentAdapter, CachedHealth>()
|
||||
private readonly cacheTtlMs: number
|
||||
private readonly probeTimeoutMs: number
|
||||
private readonly inflight = new Map<AgentAdapter, Promise<AdapterHealth>>()
|
||||
private readonly registry: AgentRuntimeRegistry
|
||||
|
||||
constructor(options: { cacheTtlMs?: number; probeTimeoutMs?: number } = {}) {
|
||||
this.cacheTtlMs = options.cacheTtlMs ?? 5 * 60 * 1000
|
||||
this.probeTimeoutMs = options.probeTimeoutMs ?? 2_000
|
||||
constructor(options: { registry?: AgentRuntimeRegistry } = {}) {
|
||||
this.registry = options.registry ?? getAgentRuntimeRegistry()
|
||||
}
|
||||
|
||||
async getHealth(adapter: AgentAdapter): Promise<AdapterHealth> {
|
||||
if (adapter === 'openclaw') {
|
||||
// OpenClaw health is derived from the gateway snapshot the
|
||||
// harness service already returns; the row component reads
|
||||
// that path. Surface a permissive default so the dot doesn't
|
||||
// spuriously light up red.
|
||||
return { healthy: true, checkedAt: Date.now() }
|
||||
}
|
||||
const now = Date.now()
|
||||
const cached = this.cache.get(adapter)
|
||||
if (cached && cached.expiresAt > now) return cached
|
||||
|
||||
const inflight = this.inflight.get(adapter)
|
||||
if (inflight) return inflight
|
||||
|
||||
const probe = this.runProbe(adapter)
|
||||
.then((result) => {
|
||||
const cacheEntry: CachedHealth = {
|
||||
...result,
|
||||
expiresAt: Date.now() + this.cacheTtlMs,
|
||||
}
|
||||
this.cache.set(adapter, cacheEntry)
|
||||
return result
|
||||
})
|
||||
.finally(() => {
|
||||
this.inflight.delete(adapter)
|
||||
})
|
||||
this.inflight.set(adapter, probe)
|
||||
return probe
|
||||
}
|
||||
|
||||
private async runProbe(adapter: AgentAdapter): Promise<AdapterHealth> {
|
||||
const command = ADAPTER_HEALTH_COMMANDS[adapter]
|
||||
if (!command) {
|
||||
return {
|
||||
healthy: false,
|
||||
reason: 'No health probe defined',
|
||||
checkedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
try {
|
||||
await execAsync(command, { timeout: this.probeTimeoutMs })
|
||||
return { healthy: true, checkedAt: Date.now() }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.debug('Adapter health probe failed', { adapter, error: message })
|
||||
return {
|
||||
healthy: false,
|
||||
reason: friendlyProbeFailure(adapter, message),
|
||||
checkedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
const runtime = this.registry.get(adapter)
|
||||
if (!runtime) return openclawFallback(adapter)
|
||||
if (runtime instanceof HostProcessAgentRuntime) await runtime.probeHealth()
|
||||
return runtimeSnapshotToHealth(runtime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes are deliberately conservative — `--version` exits zero on
|
||||
* any installed CLI and won't trigger network calls or auth flows.
|
||||
*/
|
||||
const ADAPTER_HEALTH_COMMANDS: Partial<Record<AgentAdapter, string>> = {
|
||||
claude: 'claude --version',
|
||||
codex: 'codex --version',
|
||||
function runtimeSnapshotToHealth(runtime: AgentRuntime): AdapterHealth {
|
||||
const snap = runtime.getStatusSnapshot()
|
||||
return {
|
||||
healthy: snap.isReady,
|
||||
reason: snap.isReady ? undefined : (snap.lastError ?? undefined),
|
||||
// Prefer probedAt so the timestamp reflects probe completion
|
||||
// regardless of health state. lastErrorAt is the fallback for
|
||||
// runtimes that don't emit probedAt yet (containers).
|
||||
checkedAt: snap.probedAt ?? snap.lastErrorAt ?? Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
function friendlyProbeFailure(adapter: AgentAdapter, raw: string): string {
|
||||
if (/command not found|not recognized|ENOENT/i.test(raw)) {
|
||||
return `${ADAPTER_HEALTH_COMMANDS[adapter]} failed: command not found`
|
||||
function openclawFallback(adapter: AgentAdapter): AdapterHealth {
|
||||
if (adapter === 'openclaw') return { healthy: true, checkedAt: Date.now() }
|
||||
return {
|
||||
healthy: false,
|
||||
reason: `No runtime registered for "${adapter}"`,
|
||||
checkedAt: Date.now(),
|
||||
}
|
||||
if (/timed out|ETIMEDOUT/i.test(raw)) {
|
||||
return `${ADAPTER_HEALTH_COMMANDS[adapter]} did not respond within timeout`
|
||||
}
|
||||
return raw.split('\n')[0]?.slice(0, 200) ?? raw
|
||||
}
|
||||
|
||||
@@ -84,6 +84,24 @@ export const AGENT_ADAPTER_CATALOG: AgentAdapterDescriptor[] = [
|
||||
{ id: 'adaptive', label: 'Adaptive' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hermes',
|
||||
name: 'Hermes',
|
||||
// 'default' means whatever the user configured via `hermes setup` —
|
||||
// Hermes' config.yaml is the source of truth for the model. ACP exposes
|
||||
// session/set_model but we don't surface it in Phase A.
|
||||
defaultModelId: 'default',
|
||||
defaultReasoningEffort: 'medium',
|
||||
modelControl: 'best-effort',
|
||||
// Empty list signals "no per-session model picker" — like OpenClaw.
|
||||
// Phase A.5 may dynamically populate from session/new response.
|
||||
models: [],
|
||||
reasoningEfforts: [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium', recommended: true },
|
||||
{ id: 'high', label: 'High' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function getAgentAdapterDescriptor(
|
||||
@@ -93,7 +111,12 @@ export function getAgentAdapterDescriptor(
|
||||
}
|
||||
|
||||
export function isAgentAdapter(value: unknown): value is AgentAdapter {
|
||||
return value === 'claude' || value === 'codex' || value === 'openclaw'
|
||||
return (
|
||||
value === 'claude' ||
|
||||
value === 'codex' ||
|
||||
value === 'openclaw' ||
|
||||
value === 'hermes'
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveDefaultModelId(adapter: AgentAdapter): string {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export type AgentAdapter = 'claude' | 'codex' | 'openclaw'
|
||||
export type AgentAdapter = 'claude' | 'codex' | 'openclaw' | 'hermes'
|
||||
|
||||
export type AgentPermissionMode = 'approve-all'
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type {
|
||||
PrepareAcpxAgentContextInput,
|
||||
PreparedAcpxAgentContext,
|
||||
} from '../acpx-agent-adapter'
|
||||
import {
|
||||
finishBrowserosManagedContext,
|
||||
prepareBrowserosManagedContext,
|
||||
} from '../acpx-agent-common'
|
||||
|
||||
/** Prepares Claude Code with BrowserOS agent home while preserving host Claude auth. */
|
||||
export async function prepareClaudeCodeContext(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext> {
|
||||
const common = await prepareBrowserosManagedContext(input)
|
||||
return finishBrowserosManagedContext({
|
||||
...common,
|
||||
commandEnv: {
|
||||
AGENT_HOME: common.paths.agentHome,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type {
|
||||
PrepareAcpxAgentContextInput,
|
||||
PreparedAcpxAgentContext,
|
||||
} from '../acpx-agent-adapter'
|
||||
import {
|
||||
finishBrowserosManagedContext,
|
||||
prepareBrowserosManagedContext,
|
||||
} from '../acpx-agent-common'
|
||||
import { materializeCodexHome } from '../acpx-runtime-context'
|
||||
|
||||
/** Prepares Codex with a contained CODEX_HOME and BrowserOS agent home. */
|
||||
export async function prepareCodexContext(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext> {
|
||||
const common = await prepareBrowserosManagedContext(input)
|
||||
await materializeCodexHome({
|
||||
paths: common.paths,
|
||||
skillNames: common.skillNames,
|
||||
})
|
||||
return finishBrowserosManagedContext({
|
||||
...common,
|
||||
commandEnv: {
|
||||
AGENT_HOME: common.paths.agentHome,
|
||||
CODEX_HOME: common.paths.codexHome,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AcpSessionRecord, createRuntimeStore } from 'acpx/runtime'
|
||||
import type {
|
||||
OpenAIChatMessage,
|
||||
OpenAIContentPart,
|
||||
} from '../../../api/services/openclaw/openclaw-gateway-chat-client'
|
||||
import { logger } from '../../logger'
|
||||
import type { AcpxAdapterTurnInput } from '../acpx-agent-adapter'
|
||||
import type { AgentStreamEvent } from '../types'
|
||||
|
||||
type ImageAttachment = Readonly<{ mediaType: string; data: string }>
|
||||
|
||||
export async function maybeHandleOpenClawTurn(
|
||||
input: AcpxAdapterTurnInput,
|
||||
): Promise<ReadableStream<AgentStreamEvent> | null> {
|
||||
const imageAttachments = (input.prompt.attachments ?? []).filter((a) =>
|
||||
a.mediaType.startsWith('image/'),
|
||||
)
|
||||
if (imageAttachments.length === 0 || !input.openclawGatewayChat) {
|
||||
return null
|
||||
}
|
||||
return sendOpenclawViaGateway({
|
||||
prompt: input.prompt,
|
||||
sessionStore: input.sessionStore,
|
||||
openclawGatewayChat: input.openclawGatewayChat,
|
||||
imageAttachments,
|
||||
cwd: input.prepared.cwd,
|
||||
runPrompt: input.prepared.runPrompt,
|
||||
})
|
||||
}
|
||||
|
||||
/** Handles OpenClaw image turns through the gateway HTTP chat endpoint. */
|
||||
async function sendOpenclawViaGateway(input: {
|
||||
prompt: AcpxAdapterTurnInput['prompt']
|
||||
sessionStore: AcpxAdapterTurnInput['sessionStore']
|
||||
openclawGatewayChat: NonNullable<AcpxAdapterTurnInput['openclawGatewayChat']>
|
||||
imageAttachments: ReadonlyArray<ImageAttachment>
|
||||
cwd: string
|
||||
runPrompt: string
|
||||
}): Promise<ReadableStream<AgentStreamEvent>> {
|
||||
const existingRecord = await input.sessionStore.load(input.prompt.sessionKey)
|
||||
const priorMessages = existingRecord
|
||||
? recordToOpenAIMessages(existingRecord)
|
||||
: []
|
||||
const userContent: OpenAIContentPart[] = [
|
||||
{
|
||||
type: 'text',
|
||||
text: input.runPrompt,
|
||||
},
|
||||
...input.imageAttachments.map(
|
||||
(a): OpenAIContentPart => ({
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:${a.mediaType};base64,${a.data}` },
|
||||
}),
|
||||
),
|
||||
]
|
||||
const messages: OpenAIChatMessage[] = [
|
||||
...priorMessages,
|
||||
{ role: 'user', content: userContent },
|
||||
]
|
||||
|
||||
logger.info('Agent harness gateway image turn dispatched', {
|
||||
agentId: input.prompt.agent.id,
|
||||
sessionKey: input.prompt.sessionKey,
|
||||
cwd: input.cwd,
|
||||
priorMessageCount: priorMessages.length,
|
||||
imageAttachmentCount: input.imageAttachments.length,
|
||||
})
|
||||
|
||||
const upstream = await input.openclawGatewayChat.streamTurn({
|
||||
agentId: input.prompt.agent.id,
|
||||
sessionKey: input.prompt.sessionKey,
|
||||
messages,
|
||||
signal: input.prompt.signal,
|
||||
})
|
||||
|
||||
const sessionStore = input.sessionStore
|
||||
const sessionKey = input.prompt.sessionKey
|
||||
const userMessageText = input.prompt.message
|
||||
const imageAttachments = input.imageAttachments
|
||||
let accumulated = ''
|
||||
|
||||
return new ReadableStream<AgentStreamEvent>({
|
||||
start: (controller) => {
|
||||
const reader = upstream.getReader()
|
||||
const persist = async () => {
|
||||
if (!existingRecord || !accumulated) return
|
||||
try {
|
||||
await persistGatewayTurn(
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
userMessageText,
|
||||
imageAttachments,
|
||||
accumulated,
|
||||
)
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'Failed to persist gateway image turn to acpx session record',
|
||||
{
|
||||
sessionKey,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
;(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (value.type === 'text_delta') accumulated += value.text
|
||||
controller.enqueue(value)
|
||||
}
|
||||
await persist()
|
||||
controller.close()
|
||||
} catch (err) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
controller.close()
|
||||
}
|
||||
})().catch(() => {})
|
||||
},
|
||||
cancel: () => {
|
||||
// Best-effort: cancel propagation to the gateway is tracked separately.
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function persistGatewayTurn(
|
||||
sessionStore: ReturnType<typeof createRuntimeStore>,
|
||||
sessionKey: string,
|
||||
userMessageText: string,
|
||||
imageAttachments: ReadonlyArray<ImageAttachment>,
|
||||
assistantText: string,
|
||||
): Promise<void> {
|
||||
const record = await sessionStore.load(sessionKey)
|
||||
if (!record) return
|
||||
const userContent: AcpxUserContent[] = [
|
||||
{ Text: userMessageText } as AcpxUserContent,
|
||||
]
|
||||
for (const _image of imageAttachments) {
|
||||
userContent.push({ Image: { source: 'base64' } } as AcpxUserContent)
|
||||
}
|
||||
const turnId = randomUUID()
|
||||
const updated = {
|
||||
...record,
|
||||
messages: [
|
||||
...record.messages,
|
||||
{ User: { id: `user-${turnId}`, content: userContent } },
|
||||
{ Agent: { content: [{ Text: assistantText }], tool_results: {} } },
|
||||
],
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
} as AcpSessionRecord
|
||||
await sessionStore.save(updated)
|
||||
}
|
||||
|
||||
function recordToOpenAIMessages(record: AcpSessionRecord): OpenAIChatMessage[] {
|
||||
const messages: OpenAIChatMessage[] = []
|
||||
for (const message of record.messages) {
|
||||
if (message === 'Resume') continue
|
||||
if ('User' in message) {
|
||||
const text = message.User.content
|
||||
.map(userContentToText)
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
.trim()
|
||||
if (text) messages.push({ role: 'user', content: text })
|
||||
continue
|
||||
}
|
||||
if ('Agent' in message) {
|
||||
const text = message.Agent.content
|
||||
.map((part) => ('Text' in part ? part.Text : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
if (text) messages.push({ role: 'assistant', content: text })
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
type AcpxSessionMessage = AcpSessionRecord['messages'][number]
|
||||
type AcpxUserContent = Extract<
|
||||
Exclude<AcpxSessionMessage, 'Resume'>,
|
||||
{ User: unknown }
|
||||
>['User']['content'][number]
|
||||
|
||||
function userContentToText(content: AcpxUserContent): string {
|
||||
if ('Text' in content) return unwrapPromptText(content.Text)
|
||||
if ('Mention' in content) return content.Mention.content
|
||||
if ('Image' in content) return content.Image.source ? '[image]' : ''
|
||||
return ''
|
||||
}
|
||||
|
||||
function unwrapPromptText(raw: string): string {
|
||||
const runtimeMatch = raw.match(
|
||||
/^<browseros_acpx_runtime\b[\s\S]*?<\/browseros_acpx_runtime>\n\n<user_request>\n([\s\S]*?)\n<\/user_request>$/,
|
||||
)
|
||||
if (runtimeMatch) return decodeBasicEntities(runtimeMatch[1]).trim()
|
||||
const roleMatch = raw.match(
|
||||
/^<role>[\s\S]*?<\/role>\n\n<user_request>\n([\s\S]*?)\n<\/user_request>$/,
|
||||
)
|
||||
if (roleMatch) return decodeBasicEntities(roleMatch[1]).trim()
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
function decodeBasicEntities(value: string): string {
|
||||
return value
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type {
|
||||
PrepareAcpxAgentContextInput,
|
||||
PreparedAcpxAgentContext,
|
||||
} from '../acpx-agent-adapter'
|
||||
import {
|
||||
buildBrowserosAcpPrompt,
|
||||
ensureUsableCwd,
|
||||
resolveAgentRuntimePaths,
|
||||
} from '../acpx-runtime-context'
|
||||
|
||||
export { maybeHandleOpenClawTurn } from './image-turn'
|
||||
|
||||
const OPENCLAW_BROWSEROS_ACP_INSTRUCTIONS =
|
||||
'<role>You are running inside BrowserOS through the OpenClaw ACP adapter. Use your OpenClaw identity, memory, and browser tools.</role>'
|
||||
|
||||
/**
|
||||
* Prepares OpenClaw without BrowserOS SOUL/MEMORY or BrowserOS MCP.
|
||||
* OpenClaw runs inside the gateway VM/container, so a selected host cwd is not visible there.
|
||||
*/
|
||||
export async function prepareOpenClawContext(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext> {
|
||||
const paths = resolveAgentRuntimePaths({
|
||||
browserosDir: input.browserosDir,
|
||||
agentId: input.agent.id,
|
||||
})
|
||||
await ensureUsableCwd(paths.effectiveCwd, true)
|
||||
return {
|
||||
cwd: paths.effectiveCwd,
|
||||
runtimeSessionKey: input.sessionKey,
|
||||
runPrompt: buildBrowserosAcpPrompt(
|
||||
OPENCLAW_BROWSEROS_ACP_INSTRUCTIONS,
|
||||
input.message,
|
||||
),
|
||||
commandEnv: {},
|
||||
commandIdentity: 'openclaw',
|
||||
useBrowserosMcp: false,
|
||||
openclawSessionKey: input.sessionKey,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Top-level interface every adapter runtime implements. Two abstract
|
||||
* subclasses (`ContainerAgentRuntime`, `HostProcessAgentRuntime`)
|
||||
* cover the two kinds we ship today.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ExecSpec,
|
||||
RuntimeAction,
|
||||
RuntimeCapability,
|
||||
RuntimeDescriptor,
|
||||
RuntimeStatusSnapshot,
|
||||
StateListener,
|
||||
Unsubscribe,
|
||||
} from './types'
|
||||
|
||||
export interface AgentRuntime {
|
||||
readonly descriptor: RuntimeDescriptor
|
||||
|
||||
// ── Status surface (Plane B feed) ────────────────────────────────
|
||||
getStatusSnapshot(): RuntimeStatusSnapshot
|
||||
subscribe(listener: StateListener): Unsubscribe
|
||||
getCapabilities(): ReadonlyArray<RuntimeCapability>
|
||||
|
||||
// ── Action dispatch (Plane B control) ────────────────────────────
|
||||
executeAction(
|
||||
action: RuntimeAction,
|
||||
options?: { onLog?: (msg: string) => void },
|
||||
): Promise<void>
|
||||
|
||||
// ── ACP plane integration ────────────────────────────────────────
|
||||
/**
|
||||
* Build the shell-command string acpx-core spawns to run `spec`
|
||||
* against this runtime. For container kinds, this is the
|
||||
* `limactl shell <vm> -- nerdctl exec -i …` chain; for host kinds,
|
||||
* it's `env KEY=VAL <binary> <argv...>`.
|
||||
*/
|
||||
buildExecArgv(spec: ExecSpec): string
|
||||
|
||||
// ── Filesystem ───────────────────────────────────────────────────
|
||||
/** Per-agent home dir on host. Both kinds expose this; container
|
||||
* kinds also expose `toContainerPath` for in-container translation. */
|
||||
getPerAgentHomeDir(agentId: string): string
|
||||
toContainerPath?(hostPath: string): string
|
||||
toHostPath?(containerPath: string): string
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { getBrowserosDir } from '../../browseros-dir'
|
||||
import { logger } from '../../logger'
|
||||
import type {
|
||||
PrepareAcpxAgentContextInput,
|
||||
PreparedAcpxAgentContext,
|
||||
} from '../acpx-agent-adapter'
|
||||
import {
|
||||
finishBrowserosManagedContext,
|
||||
prepareBrowserosManagedContext,
|
||||
} from '../acpx-agent-common'
|
||||
import { resolveAgentRuntimePaths } from '../acpx-runtime-context'
|
||||
import { HostProcessAgentRuntime } from './host-process-agent-runtime'
|
||||
import { getAgentRuntimeRegistry } from './registry'
|
||||
import type { RuntimeDescriptor } from './types'
|
||||
|
||||
const CLAUDE_BINARY = 'claude'
|
||||
|
||||
export interface ClaudeRuntimeConfig {
|
||||
browserosDir: string
|
||||
}
|
||||
|
||||
export class ClaudeRuntime extends HostProcessAgentRuntime {
|
||||
readonly descriptor: RuntimeDescriptor & { kind: 'host-process' } = {
|
||||
adapterId: 'claude',
|
||||
displayName: 'Claude Code',
|
||||
kind: 'host-process',
|
||||
platforms: ['darwin', 'linux'],
|
||||
}
|
||||
|
||||
private readonly claudeConfig: ClaudeRuntimeConfig
|
||||
|
||||
constructor(
|
||||
deps: ConstructorParameters<typeof HostProcessAgentRuntime>[0],
|
||||
config: ClaudeRuntimeConfig,
|
||||
) {
|
||||
super(deps)
|
||||
this.claudeConfig = config
|
||||
}
|
||||
|
||||
getPerAgentHomeDir(agentId: string): string {
|
||||
return resolveAgentRuntimePaths({
|
||||
browserosDir: this.claudeConfig.browserosDir,
|
||||
agentId,
|
||||
}).agentHome
|
||||
}
|
||||
|
||||
prepareTurnContext(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext> {
|
||||
return prepareClaudeCodeContext(input)
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepares Claude Code with BrowserOS agent home while preserving host Claude auth. */
|
||||
export async function prepareClaudeCodeContext(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext> {
|
||||
const common = await prepareBrowserosManagedContext(input)
|
||||
return finishBrowserosManagedContext({
|
||||
...common,
|
||||
commandEnv: {
|
||||
AGENT_HOME: common.paths.agentHome,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface ConfigureClaudeRuntimeOptions {
|
||||
browserosDir?: string
|
||||
}
|
||||
|
||||
export function configureClaudeRuntime(
|
||||
options: ConfigureClaudeRuntimeOptions = {},
|
||||
): ClaudeRuntime {
|
||||
const browserosDir = options.browserosDir ?? getBrowserosDir()
|
||||
const runtime = new ClaudeRuntime(
|
||||
{ binaryName: CLAUDE_BINARY },
|
||||
{ browserosDir },
|
||||
)
|
||||
getAgentRuntimeRegistry().register(runtime)
|
||||
logger.debug('ClaudeRuntime registered', { binary: CLAUDE_BINARY })
|
||||
return runtime
|
||||
}
|
||||
|
||||
export function getClaudeRuntime(): ClaudeRuntime | null {
|
||||
const r = getAgentRuntimeRegistry().get('claude')
|
||||
return r instanceof ClaudeRuntime ? r : null
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { getBrowserosDir } from '../../browseros-dir'
|
||||
import { logger } from '../../logger'
|
||||
import type {
|
||||
PrepareAcpxAgentContextInput,
|
||||
PreparedAcpxAgentContext,
|
||||
} from '../acpx-agent-adapter'
|
||||
import {
|
||||
finishBrowserosManagedContext,
|
||||
prepareBrowserosManagedContext,
|
||||
} from '../acpx-agent-common'
|
||||
import {
|
||||
materializeCodexHome,
|
||||
resolveAgentRuntimePaths,
|
||||
} from '../acpx-runtime-context'
|
||||
import { HostProcessAgentRuntime } from './host-process-agent-runtime'
|
||||
import { getAgentRuntimeRegistry } from './registry'
|
||||
import type { RuntimeDescriptor } from './types'
|
||||
|
||||
const CODEX_BINARY = 'codex'
|
||||
|
||||
export interface CodexRuntimeConfig {
|
||||
browserosDir: string
|
||||
}
|
||||
|
||||
export class CodexRuntime extends HostProcessAgentRuntime {
|
||||
readonly descriptor: RuntimeDescriptor & { kind: 'host-process' } = {
|
||||
adapterId: 'codex',
|
||||
displayName: 'Codex',
|
||||
kind: 'host-process',
|
||||
platforms: ['darwin', 'linux'],
|
||||
}
|
||||
|
||||
private readonly codexConfig: CodexRuntimeConfig
|
||||
|
||||
constructor(
|
||||
deps: ConstructorParameters<typeof HostProcessAgentRuntime>[0],
|
||||
config: CodexRuntimeConfig,
|
||||
) {
|
||||
super(deps)
|
||||
this.codexConfig = config
|
||||
}
|
||||
|
||||
getPerAgentHomeDir(agentId: string): string {
|
||||
return resolveAgentRuntimePaths({
|
||||
browserosDir: this.codexConfig.browserosDir,
|
||||
agentId,
|
||||
}).agentHome
|
||||
}
|
||||
|
||||
prepareTurnContext(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext> {
|
||||
return prepareCodexContext(input)
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepares Codex with a contained CODEX_HOME and BrowserOS agent home. */
|
||||
export async function prepareCodexContext(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext> {
|
||||
const common = await prepareBrowserosManagedContext(input)
|
||||
await materializeCodexHome({
|
||||
paths: common.paths,
|
||||
skillNames: common.skillNames,
|
||||
})
|
||||
return finishBrowserosManagedContext({
|
||||
...common,
|
||||
commandEnv: {
|
||||
AGENT_HOME: common.paths.agentHome,
|
||||
CODEX_HOME: common.paths.codexHome,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface ConfigureCodexRuntimeOptions {
|
||||
browserosDir?: string
|
||||
}
|
||||
|
||||
export function configureCodexRuntime(
|
||||
options: ConfigureCodexRuntimeOptions = {},
|
||||
): CodexRuntime {
|
||||
const browserosDir = options.browserosDir ?? getBrowserosDir()
|
||||
const runtime = new CodexRuntime(
|
||||
{ binaryName: CODEX_BINARY },
|
||||
{ browserosDir },
|
||||
)
|
||||
getAgentRuntimeRegistry().register(runtime)
|
||||
logger.debug('CodexRuntime registered', { binary: CODEX_BINARY })
|
||||
return runtime
|
||||
}
|
||||
|
||||
export function getCodexRuntime(): CodexRuntime | null {
|
||||
const r = getAgentRuntimeRegistry().get('codex')
|
||||
return r instanceof CodexRuntime ? r : null
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Abstract base for container-backed agent runtimes (openclaw,
|
||||
* hermes). Extends `ManagedContainer` so subclasses keep all the
|
||||
* existing container plumbing (state machine, lifecycle lock, image
|
||||
* load, mount roots, exec gating); adds the runtime-layer surface on
|
||||
* top: descriptor, capability list, action dispatcher, status
|
||||
* snapshot.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ContainerDescriptor,
|
||||
ContainerStatusSnapshot,
|
||||
} from '../../container/managed'
|
||||
import { ManagedContainer } from '../../container/managed'
|
||||
import type { AgentRuntime } from './agent-runtime'
|
||||
import { ActionNotSupportedError } from './errors'
|
||||
import type {
|
||||
RuntimeAction,
|
||||
RuntimeCapability,
|
||||
StateListener,
|
||||
Unsubscribe,
|
||||
} from './types'
|
||||
|
||||
export abstract class ContainerAgentRuntime
|
||||
extends ManagedContainer
|
||||
implements AgentRuntime
|
||||
{
|
||||
abstract override readonly descriptor: ContainerDescriptor & {
|
||||
kind: 'container'
|
||||
}
|
||||
abstract getPerAgentHomeDir(agentId: string): string
|
||||
|
||||
/**
|
||||
* Default capability list. Subclasses extend (e.g. OpenClaw adds
|
||||
* `'gateway-control-plane'`) or filter (e.g. drop reset levels the
|
||||
* subclass can't support yet).
|
||||
*/
|
||||
getCapabilities(): ReadonlyArray<RuntimeCapability> {
|
||||
return [
|
||||
'install',
|
||||
'start',
|
||||
'stop',
|
||||
'restart',
|
||||
'reset-soft',
|
||||
'reset-wipe-agent',
|
||||
'reset-hard',
|
||||
'logs',
|
||||
]
|
||||
}
|
||||
|
||||
override getStatusSnapshot(): ContainerStatusSnapshot & {
|
||||
isReady: boolean
|
||||
} {
|
||||
const state = this.getState()
|
||||
return {
|
||||
adapterId: this.descriptor.adapterId,
|
||||
containerName: this.descriptor.containerName,
|
||||
state,
|
||||
isReady: state === 'running',
|
||||
lastError: this.lastError,
|
||||
lastErrorAt: this.lastErrorAt,
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: StateListener): Unsubscribe {
|
||||
return this.subscribeState(() => listener(this.getStatusSnapshot()))
|
||||
}
|
||||
|
||||
async executeAction(
|
||||
action: RuntimeAction,
|
||||
opts: { onLog?: (msg: string) => void } = {},
|
||||
): Promise<void> {
|
||||
const required = actionToCapability(action)
|
||||
if (!this.getCapabilities().includes(required)) {
|
||||
throw new ActionNotSupportedError(
|
||||
this.descriptor.adapterId,
|
||||
action.type,
|
||||
this.getCapabilities(),
|
||||
)
|
||||
}
|
||||
switch (action.type) {
|
||||
case 'install':
|
||||
return this.install(opts)
|
||||
case 'start':
|
||||
return this.start(opts)
|
||||
case 'stop':
|
||||
return this.stop()
|
||||
case 'restart':
|
||||
return this.restart(opts)
|
||||
case 'reset-soft':
|
||||
return this.reset('soft', opts)
|
||||
case 'reset-wipe-agent':
|
||||
return this.reset('wipe-agent', { ...opts, agentId: action.agentId })
|
||||
case 'reset-hard':
|
||||
return this.reset('hard', opts)
|
||||
default:
|
||||
throw new ActionNotSupportedError(
|
||||
this.descriptor.adapterId,
|
||||
(action as { type: string }).type,
|
||||
this.getCapabilities(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an action variant to the capability key the gate checks. Kept
|
||||
* outside the class so the dispatcher can guard before constructing
|
||||
* any state.
|
||||
*/
|
||||
function actionToCapability(action: RuntimeAction): RuntimeCapability {
|
||||
// The action.type strings happen to coincide with capability
|
||||
// strings 1:1, so this is currently identity. Pulled out as a
|
||||
// function so the gate can grow more nuanced (e.g. action-specific
|
||||
// sub-capabilities) without re-flowing the dispatcher.
|
||||
return action.type as RuntimeCapability
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
/**
|
||||
* Thrown when `executeAction` is called with an action that the
|
||||
* runtime's `getCapabilities()` doesn't list. The HTTP route layer
|
||||
* maps this to 405; the UI gates affordances on capabilities so a
|
||||
* well-behaved client should never trip this.
|
||||
*/
|
||||
export class ActionNotSupportedError extends Error {
|
||||
constructor(
|
||||
public readonly adapterId: string,
|
||||
public readonly actionType: string,
|
||||
public readonly capabilities: ReadonlyArray<string>,
|
||||
) {
|
||||
super(
|
||||
`Runtime "${adapterId}" does not support action "${actionType}" ` +
|
||||
`(capabilities: ${capabilities.join(', ') || 'none'})`,
|
||||
)
|
||||
this.name = 'ActionNotSupportedError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-level "runtime is not ready to take a turn" error. Mirrors
|
||||
* `ContainerNotReadyError` from the container layer but lives at the
|
||||
* runtime layer so callers can differentiate "container abstraction
|
||||
* says no" from "host CLI is missing" without reaching down two
|
||||
* layers.
|
||||
*/
|
||||
export class RuntimeNotReadyError extends Error {
|
||||
constructor(
|
||||
public readonly adapterId: string,
|
||||
public readonly state: string,
|
||||
public readonly hint: string,
|
||||
) {
|
||||
super(`Runtime "${adapterId}" is not ready (state=${state}): ${hint}`)
|
||||
this.name = 'RuntimeNotReadyError'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Hermes-specific runtime. Owns the container spec, readiness probe,
|
||||
* mount roots, ACP launch spec, and per-turn context prep — the full
|
||||
* adapter surface lives in this single class.
|
||||
*/
|
||||
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
HERMES_CONTAINER_HARNESS_DIR,
|
||||
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'
|
||||
import type {
|
||||
ContainerDescriptor,
|
||||
ManagedContainerDeps,
|
||||
MountRoot,
|
||||
} from '../../container/managed'
|
||||
import type { ContainerSpec } from '../../container/types'
|
||||
import { logger } from '../../logger'
|
||||
import {
|
||||
GUEST_VM_STATE,
|
||||
getLimaHomeDir,
|
||||
resolveBundledLimactl,
|
||||
resolveBundledLimaTemplate,
|
||||
VM_NAME,
|
||||
VmRuntime,
|
||||
} from '../../vm'
|
||||
import type {
|
||||
PrepareAcpxAgentContextInput,
|
||||
PreparedAcpxAgentContext,
|
||||
} from '../acpx-agent-adapter'
|
||||
import {
|
||||
finishBrowserosManagedContext,
|
||||
prepareBrowserosManagedContext,
|
||||
} from '../acpx-agent-common'
|
||||
import { ContainerAgentRuntime } from './container-agent-runtime'
|
||||
import { getAgentRuntimeRegistry } from './registry'
|
||||
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
|
||||
}
|
||||
|
||||
export class HermesContainerRuntime extends ContainerAgentRuntime {
|
||||
readonly descriptor: ContainerDescriptor & { kind: 'container' } = {
|
||||
adapterId: 'hermes',
|
||||
displayName: 'Hermes',
|
||||
kind: 'container',
|
||||
defaultImage: HERMES_IMAGE,
|
||||
containerName: HERMES_CONTAINER_NAME,
|
||||
platforms: ['darwin'],
|
||||
// Hermes has no HTTP probe; we exec `hermes --version` instead
|
||||
// (see `readinessProbe` below). Generous timeout because the
|
||||
// first exec inside a freshly-started container can be slow.
|
||||
readinessProbe: { timeoutMs: 30_000, intervalMs: 500 },
|
||||
}
|
||||
|
||||
private readonly hermesConfig: HermesContainerRuntimeConfig
|
||||
|
||||
constructor(
|
||||
deps: ManagedContainerDeps,
|
||||
config: HermesContainerRuntimeConfig,
|
||||
) {
|
||||
super(deps)
|
||||
this.hermesConfig = config
|
||||
}
|
||||
|
||||
// ── ManagedContainer abstracts ───────────────────────────────────
|
||||
|
||||
protected mountRoots(): readonly MountRoot[] {
|
||||
return [
|
||||
{
|
||||
hostPath: this.hermesConfig.hermesHarnessHostDir,
|
||||
containerPath: HERMES_CONTAINER_HARNESS_DIR,
|
||||
kind: 'shared',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
protected async buildContainerSpec(): Promise<ContainerSpec> {
|
||||
// The bind-mount source is an in-VM path, not the host path —
|
||||
// Lima's bundled mount already exposes <browserosDir>/vm/ to the
|
||||
// VM at GUEST_VM_STATE, so nerdctl sees the harness dir at
|
||||
// `${GUEST_VM_STATE}/hermes/harness`. mountRoots() above declares
|
||||
// the *logical* host↔container mapping for path-translation use.
|
||||
const guestHarnessDir = `${GUEST_VM_STATE}/hermes/harness`
|
||||
const gateway = await this.deps.vm.getDefaultGateway()
|
||||
return {
|
||||
name: HERMES_CONTAINER_NAME,
|
||||
image: HERMES_IMAGE,
|
||||
restart: 'unless-stopped',
|
||||
env: { PYTHONUNBUFFERED: '1' },
|
||||
// host.containers.internal → VM gateway so hermes inside the
|
||||
// container can reach the BrowserOS HTTP server running on the
|
||||
// host (BrowserOS MCP /mcp).
|
||||
addHosts: [`host.containers.internal:${gateway}`],
|
||||
mounts: [
|
||||
{ source: guestHarnessDir, target: HERMES_CONTAINER_HARNESS_DIR },
|
||||
],
|
||||
// Override the upstream image's `hermes acp` ENTRYPOINT — we
|
||||
// want a long-lived idle container that we `nerdctl exec` into
|
||||
// per turn. Bypass tini (0.19.0 getopt-parses `-x` even after
|
||||
// the PROGRAM, so `tini /bin/sh -c "…"` errors).
|
||||
entrypoint: '/bin/sh',
|
||||
command: ['-c', 'exec sleep infinity'],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Container-running is already checked by the base via
|
||||
* `cli.waitForContainerRunning` before this runs. Here we add an
|
||||
* exec-based liveness check: `hermes --version` exits 0. Catches
|
||||
* the failure mode where the container daemon thinks it's running
|
||||
* but the embedded Python venv is broken or the binary is missing.
|
||||
*
|
||||
* This must NOT go through `execProcess` — that would deadlock on
|
||||
* the state gate (we're in `starting`, not `running`). Use the
|
||||
* lower-level `cli.exec` directly.
|
||||
*/
|
||||
protected async readinessProbe(): Promise<boolean> {
|
||||
try {
|
||||
const exitCode = await this.deps.cli.exec(this.descriptor.containerName, [
|
||||
HERMES_BINARY,
|
||||
'--version',
|
||||
])
|
||||
return exitCode === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ── AgentRuntime additions ───────────────────────────────────────
|
||||
|
||||
getPerAgentHomeDir(agentId: string): string {
|
||||
return getHermesAgentHomeHostDir({
|
||||
browserosDir: this.hermesConfig.browserosDir,
|
||||
agentId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* ExecSpec for `hermes acp`. The dispatcher feeds this to
|
||||
* `buildExecArgv()` (inherited from `ManagedContainer`) to get the
|
||||
* launch command string. PYTHONUNBUFFERED is re-added defensively —
|
||||
* the container has it set too, but acpx spawns through `nerdctl
|
||||
* exec` which doesn't inherit container env onto the new process.
|
||||
*/
|
||||
getAcpExecSpec(commandEnv: Record<string, string>): ExecSpec {
|
||||
return {
|
||||
argv: [HERMES_BINARY, 'acp'],
|
||||
env: { PYTHONUNBUFFERED: '1', ...commandEnv },
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-turn context prep — thin wrapper around the standalone
|
||||
* `prepareHermesContext` so callers that prefer the runtime-style
|
||||
* surface stay self-contained. */
|
||||
prepareTurnContext(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext> {
|
||||
return prepareHermesContext(input)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a host-side hermes home path to its in-container equivalent.
|
||||
* The container bind-mounts `<browserosDir>/vm/hermes/harness` (host)
|
||||
* onto `/data/agents/harness` (container), so paths under the host
|
||||
* harness root map cleanly to `/data/agents/harness/...` inside.
|
||||
*
|
||||
* Returns the original host path when it doesn't sit under the harness
|
||||
* root — defensive escape hatch for tests that inject a custom dir.
|
||||
*/
|
||||
function translateHermesHomeToContainerPath(
|
||||
hostHome: string,
|
||||
browserosDir: 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)}`
|
||||
}
|
||||
return hostHome
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares Hermes with a per-agent HERMES_HOME under
|
||||
* `<browserosDir>/vm/hermes/harness/<id>/home`. Provider config
|
||||
* (config.yaml + .env) is written into this directory at agent-create
|
||||
* time by AgentHarnessService.writeHermesPerAgentProvider. There is no
|
||||
* fallback to a global `~/.hermes/` install — Hermes agents always
|
||||
* carry their own provider config.
|
||||
*
|
||||
* HERMES_HOME inside the container is the container-side path
|
||||
* (`/data/agents/harness/<id>/home`) so Hermes resolves it correctly
|
||||
* when the runtime spawns `hermes acp` via `nerdctl exec`.
|
||||
*
|
||||
* Pure function — no runtime instance required, used directly by
|
||||
* the per-adapter prepare router in `acpx-agent-adapter.ts`.
|
||||
*/
|
||||
export async function prepareHermesContext(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext> {
|
||||
const common = await prepareBrowserosManagedContext(input)
|
||||
|
||||
// Hermes-specific home lives under vm/ so it's reachable inside the
|
||||
// Lima VM; the shared `common.paths.agentHome` (under agents/harness)
|
||||
// is OUTSIDE the VM mount and would not be visible to nerdctl.
|
||||
const hermesAgentHome = getHermesAgentHomeHostDir({
|
||||
browserosDir: input.browserosDir,
|
||||
agentId: input.agent.id,
|
||||
})
|
||||
await mkdir(hermesAgentHome, { recursive: true })
|
||||
|
||||
const hermesAgentHomeInContainer = translateHermesHomeToContainerPath(
|
||||
hermesAgentHome,
|
||||
input.browserosDir,
|
||||
)
|
||||
|
||||
return finishBrowserosManagedContext({
|
||||
...common,
|
||||
commandEnv: {
|
||||
HERMES_HOME: hermesAgentHomeInContainer,
|
||||
},
|
||||
// Hermes runs inside a Lima container; the BrowserOS HTTP MCP
|
||||
// server lives on the host. `host.containers.internal` resolves
|
||||
// to the VM gateway (via --add-host on the hermes container) so
|
||||
// hermes can reach the MCP endpoint that the harness injects via
|
||||
// newSession.
|
||||
browserosMcpHost: 'host.containers.internal',
|
||||
})
|
||||
}
|
||||
|
||||
// ── Factory + wire-up ──────────────────────────────────────────────
|
||||
|
||||
export interface ConfigureHermesRuntimeOptions {
|
||||
/** Bundled-resources root (provided by the launcher); when set,
|
||||
* resolves bundled limactl + Lima template paths instead of host
|
||||
* defaults. Optional in tests. */
|
||||
resourcesDir?: string
|
||||
/** Override BrowserOS state dir (defaults to `getBrowserosDir()`). */
|
||||
browserosDir?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `HermesContainerRuntime` with production deps (bundled
|
||||
* limactl, BrowserOS state dirs, Lima VM runtime) and register it in
|
||||
* the global `AgentRuntimeRegistry`. Returns `null` on non-darwin —
|
||||
* the harness checks for the runtime and falls back gracefully.
|
||||
*
|
||||
* Idempotent against accidental double-init only insofar as the
|
||||
* registry's duplicate guard fires; callers should call this once at
|
||||
* server startup.
|
||||
*/
|
||||
export function configureHermesRuntime(
|
||||
options: ConfigureHermesRuntimeOptions = {},
|
||||
): HermesContainerRuntime | null {
|
||||
if (process.platform !== 'darwin') {
|
||||
logger.warn('Hermes runtime skipped: unsupported platform', {
|
||||
platform: process.platform,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const browserosDir = options.browserosDir ?? getBrowserosDir()
|
||||
const resourcesDir = options.resourcesDir ?? null
|
||||
const limactlPath = resourcesDir
|
||||
? resolveBundledLimactl(resourcesDir)
|
||||
: 'limactl'
|
||||
const limaHome = getLimaHomeDir(browserosDir)
|
||||
const hermesStateDir = getHermesHostStateDir(browserosDir)
|
||||
const hermesHarnessHostDir = getHermesHarnessHostDir(browserosDir)
|
||||
|
||||
const vm = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
templatePath: resourcesDir
|
||||
? resolveBundledLimaTemplate(resourcesDir)
|
||||
: undefined,
|
||||
browserosRoot: browserosDir,
|
||||
})
|
||||
const cli = new ContainerCli({ limactlPath, limaHome, vmName: VM_NAME })
|
||||
const loader = new ImageLoader(cli)
|
||||
|
||||
const runtime = new HermesContainerRuntime(
|
||||
{
|
||||
cli,
|
||||
loader,
|
||||
vm,
|
||||
limactlPath,
|
||||
limaHome,
|
||||
vmName: VM_NAME,
|
||||
lockDir: join(hermesStateDir, '.locks'),
|
||||
},
|
||||
{ browserosDir, hermesHarnessHostDir },
|
||||
)
|
||||
|
||||
getAgentRuntimeRegistry().register(runtime)
|
||||
logger.debug('HermesContainerRuntime registered', { image: HERMES_IMAGE })
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Abstract base for host-process agent runtimes (claude, codex). The
|
||||
* agent process runs from the user's host PATH — no container, no
|
||||
* Lima. This class owns binary discovery, version probing with
|
||||
* caching, and the smaller capability surface that host adapters
|
||||
* support.
|
||||
*/
|
||||
|
||||
import { logger } from '../../logger'
|
||||
import type { AgentRuntime } from './agent-runtime'
|
||||
import { ActionNotSupportedError } from './errors'
|
||||
import type {
|
||||
ExecSpec,
|
||||
RuntimeAction,
|
||||
RuntimeCapability,
|
||||
RuntimeDescriptor,
|
||||
RuntimeState,
|
||||
RuntimeStatusSnapshot,
|
||||
StateListener,
|
||||
Unsubscribe,
|
||||
} from './types'
|
||||
|
||||
export interface HostProcessAgentRuntimeDeps {
|
||||
/** Host PATH binary name to probe + spawn (e.g. 'claude', 'codex'). */
|
||||
binaryName: string
|
||||
/** Override the default `<binary> --version` probe argv. */
|
||||
versionProbeArgs?: ReadonlyArray<string>
|
||||
/** Cache window for probe results in ms. Default 5 minutes — same
|
||||
* as today's adapter-health.ts. */
|
||||
probeCacheMs?: number
|
||||
/** Test seam: spawn the probe via this fn instead of `Bun.$`. */
|
||||
spawnProbe?: (
|
||||
cmd: ReadonlyArray<string>,
|
||||
timeoutMs: number,
|
||||
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
|
||||
}
|
||||
|
||||
const DEFAULT_PROBE_CACHE_MS = 5 * 60 * 1000
|
||||
const DEFAULT_PROBE_TIMEOUT_MS = 2_000
|
||||
|
||||
export abstract class HostProcessAgentRuntime implements AgentRuntime {
|
||||
abstract readonly descriptor: RuntimeDescriptor & { kind: 'host-process' }
|
||||
abstract getPerAgentHomeDir(agentId: string): string
|
||||
|
||||
protected state: RuntimeState = 'cli_missing'
|
||||
protected lastError: string | null = null
|
||||
protected lastErrorAt: number | null = null
|
||||
protected binaryVersion: string | null = null
|
||||
private readonly listeners = new Set<StateListener>()
|
||||
private healthCheckedAt = 0
|
||||
private probeInFlight: Promise<void> | null = null
|
||||
|
||||
constructor(protected readonly deps: HostProcessAgentRuntimeDeps) {}
|
||||
|
||||
// ── Status surface ───────────────────────────────────────────────
|
||||
|
||||
getStatusSnapshot(): RuntimeStatusSnapshot {
|
||||
return {
|
||||
adapterId: this.descriptor.adapterId,
|
||||
state: this.state,
|
||||
isReady: this.state === 'cli_present',
|
||||
lastError: this.lastError,
|
||||
lastErrorAt: this.lastErrorAt,
|
||||
probedAt: this.healthCheckedAt > 0 ? this.healthCheckedAt : null,
|
||||
details: { binaryVersion: this.binaryVersion },
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: StateListener): Unsubscribe {
|
||||
this.listeners.add(listener)
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
getCapabilities(): ReadonlyArray<RuntimeCapability> {
|
||||
return ['reinstall-cli', 'check-auth']
|
||||
}
|
||||
|
||||
// ── Action dispatch ──────────────────────────────────────────────
|
||||
|
||||
async executeAction(
|
||||
action: RuntimeAction,
|
||||
_opts: { onLog?: (msg: string) => void } = {},
|
||||
): Promise<void> {
|
||||
if (!this.getCapabilities().includes(action.type as RuntimeCapability)) {
|
||||
throw new ActionNotSupportedError(
|
||||
this.descriptor.adapterId,
|
||||
action.type,
|
||||
this.getCapabilities(),
|
||||
)
|
||||
}
|
||||
switch (action.type) {
|
||||
case 'reinstall-cli':
|
||||
return this.handleReinstallCli()
|
||||
case 'check-auth':
|
||||
return this.checkAuth()
|
||||
default:
|
||||
throw new ActionNotSupportedError(
|
||||
this.descriptor.adapterId,
|
||||
action.type,
|
||||
this.getCapabilities(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ACP plane integration ────────────────────────────────────────
|
||||
|
||||
buildExecArgv(spec: ExecSpec): string {
|
||||
// Host binary lives on $PATH — no limactl chain. Compose
|
||||
// `env KEY=val ... <argv...>` so adapters that inject env
|
||||
// (AGENT_HOME, CODEX_HOME) get them on the spawned process.
|
||||
const envParts = Object.entries(spec.env ?? {}).map(([k, v]) => `${k}=${v}`)
|
||||
const prefix = envParts.length > 0 ? `env ${envParts.join(' ')} ` : ''
|
||||
return `${prefix}${spec.argv.join(' ')}`
|
||||
}
|
||||
|
||||
// ── Health probe ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Probe `<binary> --version` (override via deps.versionProbeArgs).
|
||||
* Cached for `probeCacheMs`. Updates state + binaryVersion +
|
||||
* fires subscribers. Idempotent within the cache window.
|
||||
*/
|
||||
async probeHealth(force = false): Promise<void> {
|
||||
const cacheMs = this.deps.probeCacheMs ?? DEFAULT_PROBE_CACHE_MS
|
||||
const now = Date.now()
|
||||
if (!force && now - this.healthCheckedAt < cacheMs) return
|
||||
// Concurrent callers race past the cache check when the cache is
|
||||
// stale or never stamped (spawn-failure path). Coalesce them onto
|
||||
// the same probe so we never spawn duplicate `--version` processes.
|
||||
if (this.probeInFlight) return this.probeInFlight
|
||||
this.probeInFlight = this.runProbeOnce().finally(() => {
|
||||
this.probeInFlight = null
|
||||
})
|
||||
return this.probeInFlight
|
||||
}
|
||||
|
||||
private async runProbeOnce(): Promise<void> {
|
||||
const argv = this.deps.versionProbeArgs ?? [
|
||||
this.deps.binaryName,
|
||||
'--version',
|
||||
]
|
||||
try {
|
||||
const result = await this.runProbe(argv, DEFAULT_PROBE_TIMEOUT_MS)
|
||||
this.healthCheckedAt = Date.now()
|
||||
if (result.exitCode === 0) {
|
||||
this.binaryVersion = result.stdout.trim() || null
|
||||
this.setState('cli_present')
|
||||
} else {
|
||||
this.binaryVersion = null
|
||||
this.setState(
|
||||
'cli_unhealthy',
|
||||
`${this.deps.binaryName} --version exited ${result.exitCode}: ${result.stderr.trim() || '(no stderr)'}`,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
// Spawn failure (binary missing, perm denied) leaves the cache
|
||||
// unstamped so the next call re-probes; the inflight promise
|
||||
// above still prevents *concurrent* duplicates.
|
||||
this.binaryVersion = null
|
||||
this.setState(
|
||||
'cli_missing',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Subclass hooks ───────────────────────────────────────────────
|
||||
|
||||
/** Subclass override — claude reads ~/.claude/auth.json, codex
|
||||
* reads <CODEX_HOME>/auth.json, etc. Default is a no-op. */
|
||||
protected async checkAuth(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
/** Default reinstall-cli handler — throws an informative error
|
||||
* pointing at the upstream docs. Subclasses can override to
|
||||
* trigger an in-app installer. */
|
||||
protected async handleReinstallCli(): Promise<void> {
|
||||
throw new Error(
|
||||
`${this.descriptor.displayName} CLI is not installed. ` +
|
||||
`Install ${this.deps.binaryName} from the upstream docs and probe again.`,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────
|
||||
|
||||
protected setState(next: RuntimeState, errorMessage?: string): void {
|
||||
if (next === this.state && !errorMessage) return
|
||||
this.state = next
|
||||
if (errorMessage !== undefined) {
|
||||
this.lastError = errorMessage
|
||||
this.lastErrorAt = Date.now()
|
||||
} else if (next === 'cli_present') {
|
||||
this.lastError = null
|
||||
this.lastErrorAt = null
|
||||
}
|
||||
const snapshot = this.getStatusSnapshot()
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(snapshot)
|
||||
} catch (err) {
|
||||
logger.warn('HostProcessAgentRuntime state listener threw', {
|
||||
adapterId: this.descriptor.adapterId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runProbe(
|
||||
cmd: ReadonlyArray<string>,
|
||||
timeoutMs: number,
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
if (this.deps.spawnProbe) return this.deps.spawnProbe(cmd, timeoutMs)
|
||||
const proc = Bun.spawn(cmd as string[], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
proc.kill()
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}, timeoutMs)
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
])
|
||||
clearTimeout(timer)
|
||||
return { exitCode, stdout, stderr }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export type { AgentRuntime } from './agent-runtime'
|
||||
export {
|
||||
ClaudeRuntime,
|
||||
type ClaudeRuntimeConfig,
|
||||
type ConfigureClaudeRuntimeOptions,
|
||||
configureClaudeRuntime,
|
||||
getClaudeRuntime,
|
||||
prepareClaudeCodeContext,
|
||||
} from './claude-host-process-runtime'
|
||||
export {
|
||||
CodexRuntime,
|
||||
type CodexRuntimeConfig,
|
||||
type ConfigureCodexRuntimeOptions,
|
||||
configureCodexRuntime,
|
||||
getCodexRuntime,
|
||||
prepareCodexContext,
|
||||
} from './codex-host-process-runtime'
|
||||
export { ContainerAgentRuntime } from './container-agent-runtime'
|
||||
export { ActionNotSupportedError, RuntimeNotReadyError } from './errors'
|
||||
export {
|
||||
type ConfigureHermesRuntimeOptions,
|
||||
configureHermesRuntime,
|
||||
getHermesRuntime,
|
||||
HermesContainerRuntime,
|
||||
type HermesContainerRuntimeConfig,
|
||||
prepareHermesContext,
|
||||
} from './hermes-container-runtime'
|
||||
export {
|
||||
HostProcessAgentRuntime,
|
||||
type HostProcessAgentRuntimeDeps,
|
||||
} from './host-process-agent-runtime'
|
||||
export {
|
||||
type ConfigureOpenClawRuntimeOptions,
|
||||
configureOpenClawRuntime,
|
||||
getOpenClawRuntime,
|
||||
OpenClawContainerRuntime,
|
||||
type OpenClawContainerRuntimeConfig,
|
||||
prepareOpenClawContext,
|
||||
} from './openclaw-container-runtime'
|
||||
export {
|
||||
AgentRuntimeRegistry,
|
||||
getAgentRuntimeRegistry,
|
||||
resetAgentRuntimeRegistry,
|
||||
} from './registry'
|
||||
export type {
|
||||
ExecSpec,
|
||||
Platform,
|
||||
RuntimeAction,
|
||||
RuntimeCapability,
|
||||
RuntimeDescriptor,
|
||||
RuntimeState,
|
||||
RuntimeStatusSnapshot,
|
||||
StateListener,
|
||||
Unsubscribe,
|
||||
} from './types'
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
OPENCLAW_IMAGE,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { getOpenClawStateEnvPath } from '../../../api/services/openclaw/openclaw-env'
|
||||
import { getBrowserosDir, getOpenClawDir } from '../../browseros-dir'
|
||||
import { ContainerCli } from '../../container/container-cli'
|
||||
import { ImageLoader } from '../../container/image-loader'
|
||||
import type {
|
||||
ContainerDescriptor,
|
||||
ManagedContainerDeps,
|
||||
MountRoot,
|
||||
} from '../../container/managed'
|
||||
import type { ContainerSpec, LogFn } from '../../container/types'
|
||||
import { logger } from '../../logger'
|
||||
import {
|
||||
GUEST_VM_STATE,
|
||||
getLimaHomeDir,
|
||||
resolveBundledLimactl,
|
||||
resolveBundledLimaTemplate,
|
||||
VM_NAME,
|
||||
VmRuntime,
|
||||
} from '../../vm'
|
||||
import type {
|
||||
PrepareAcpxAgentContextInput,
|
||||
PreparedAcpxAgentContext,
|
||||
} from '../acpx-agent-adapter'
|
||||
import {
|
||||
buildBrowserosAcpPrompt,
|
||||
ensureUsableCwd,
|
||||
resolveAgentRuntimePaths,
|
||||
} from '../acpx-runtime-context'
|
||||
import { ContainerAgentRuntime } from './container-agent-runtime'
|
||||
import { getAgentRuntimeRegistry } from './registry'
|
||||
import type { ExecSpec } from './types'
|
||||
|
||||
const GATEWAY_CONTAINER_HOME = '/home/node'
|
||||
const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
|
||||
const GUEST_OPENCLAW_HOME = `${GUEST_VM_STATE}/openclaw`
|
||||
const GATEWAY_NPM_PREFIX = `${GATEWAY_CONTAINER_HOME}/.npm-global`
|
||||
const GATEWAY_PATH = [
|
||||
`${GATEWAY_NPM_PREFIX}/bin`,
|
||||
'/usr/local/sbin',
|
||||
'/usr/local/bin',
|
||||
'/usr/sbin',
|
||||
'/usr/bin',
|
||||
'/sbin',
|
||||
'/bin',
|
||||
].join(':')
|
||||
|
||||
const OPENCLAW_BROWSEROS_ACP_INSTRUCTIONS =
|
||||
'<role>You are running inside BrowserOS through the OpenClaw ACP adapter. Use your OpenClaw identity, memory, and browser tools.</role>'
|
||||
|
||||
export interface OpenClawContainerRuntimeConfig {
|
||||
/** BrowserOS state root. */
|
||||
browserosDir: string
|
||||
/** OpenClaw state dir (`<browserosDir>/vm/openclaw`). */
|
||||
openclawDir: string
|
||||
}
|
||||
|
||||
export class OpenClawContainerRuntime extends ContainerAgentRuntime {
|
||||
readonly descriptor: ContainerDescriptor & { kind: 'container' } = {
|
||||
adapterId: 'openclaw',
|
||||
displayName: 'OpenClaw',
|
||||
kind: 'container',
|
||||
defaultImage: process.env.OPENCLAW_IMAGE?.trim() || OPENCLAW_IMAGE,
|
||||
containerName: OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
platforms: ['darwin'],
|
||||
readinessProbe: { timeoutMs: 60_000, intervalMs: 1_000 },
|
||||
}
|
||||
|
||||
private readonly openclawConfig: OpenClawContainerRuntimeConfig
|
||||
private hostPort: number = OPENCLAW_GATEWAY_CONTAINER_PORT
|
||||
|
||||
constructor(
|
||||
deps: ManagedContainerDeps,
|
||||
config: OpenClawContainerRuntimeConfig,
|
||||
) {
|
||||
super(deps)
|
||||
this.openclawConfig = config
|
||||
}
|
||||
|
||||
/** Service owns port allocation; the runtime re-reads it at spec-build and probe time. */
|
||||
setHostPort(port: number): void {
|
||||
this.hostPort = port
|
||||
}
|
||||
|
||||
getHostPort(): number {
|
||||
return this.hostPort
|
||||
}
|
||||
|
||||
// ── ManagedContainer abstracts ───────────────────────────────────
|
||||
|
||||
protected mountRoots(): readonly MountRoot[] {
|
||||
return [
|
||||
{
|
||||
hostPath: this.openclawConfig.openclawDir,
|
||||
containerPath: GATEWAY_CONTAINER_HOME,
|
||||
kind: 'shared',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
protected async buildContainerSpec(): Promise<ContainerSpec> {
|
||||
const hostPort = this.hostPort
|
||||
const envFilePath = getOpenClawStateEnvPath(this.openclawConfig.openclawDir)
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const gateway = await this.deps.vm.getDefaultGateway()
|
||||
return {
|
||||
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
image: this.descriptor.defaultImage,
|
||||
restart: 'unless-stopped',
|
||||
ports: [
|
||||
{
|
||||
hostIp: '127.0.0.1',
|
||||
hostPort,
|
||||
containerPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
},
|
||||
],
|
||||
envFile: this.translateHostPathToGuest(envFilePath),
|
||||
env: this.buildGatewayEnv(timezone),
|
||||
mounts: [{ source: GUEST_OPENCLAW_HOME, target: GATEWAY_CONTAINER_HOME }],
|
||||
addHosts: [`host.containers.internal:${gateway}`],
|
||||
health: {
|
||||
cmd: `curl -sf http://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}/healthz`,
|
||||
interval: '30s',
|
||||
timeout: '10s',
|
||||
retries: 3,
|
||||
},
|
||||
command: [
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'gateway',
|
||||
'--bind',
|
||||
'lan',
|
||||
'--port',
|
||||
String(OPENCLAW_GATEWAY_CONTAINER_PORT),
|
||||
'--allow-unconfigured',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
protected async readinessProbe(): Promise<boolean> {
|
||||
const hostPort = this.hostPort
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${hostPort}/readyz`)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ── AgentRuntime additions ───────────────────────────────────────
|
||||
|
||||
getPerAgentHomeDir(_agentId: string): string {
|
||||
return this.openclawConfig.openclawDir
|
||||
}
|
||||
|
||||
/** Build the ExecSpec for `openclaw acp` inside the gateway container. */
|
||||
getAcpExecSpec(input: {
|
||||
commandEnv: Record<string, string>
|
||||
openclawSessionKey: string | null
|
||||
}): ExecSpec {
|
||||
const argv: [string, ...string[]] = ['openclaw', 'acp']
|
||||
argv.push('--url', `ws://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}`)
|
||||
const bridgeSessionKey = normalizeBridgeSessionKey(input.openclawSessionKey)
|
||||
if (bridgeSessionKey) argv.push('--session', bridgeSessionKey)
|
||||
return {
|
||||
argv,
|
||||
env: {
|
||||
OPENCLAW_HIDE_BANNER: '1',
|
||||
OPENCLAW_SUPPRESS_NOTES: '1',
|
||||
...input.commandEnv,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
prepareTurnContext(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext> {
|
||||
return prepareOpenClawContext(input)
|
||||
}
|
||||
|
||||
// ── OpenClaw-specific surface kept on the runtime ────────────────
|
||||
|
||||
/** Run argv in the gateway container; satisfies OpenClawCliClient's ContainerExecutor. */
|
||||
async execInContainer(command: string[], onLog?: LogFn): Promise<number> {
|
||||
return this.deps.cli.exec(this.descriptor.containerName, command, onLog)
|
||||
}
|
||||
|
||||
/** Run argv in the gateway container with stdout + stderr captured separately. */
|
||||
async runInContainer(
|
||||
command: string[],
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
return this.deps.cli.runCommand([
|
||||
'exec',
|
||||
this.descriptor.containerName,
|
||||
...command,
|
||||
])
|
||||
}
|
||||
|
||||
/** Standalone VM-ready entry point used by prewarm / auto-start gating. */
|
||||
async ensureReady(onLog?: LogFn): Promise<void> {
|
||||
await this.deps.vm.ensureReady(onLog)
|
||||
await this.deps.vm.getDefaultGateway()
|
||||
}
|
||||
|
||||
async stopVm(): Promise<void> {
|
||||
await this.deps.vm.stopVm()
|
||||
}
|
||||
|
||||
async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
const running = await this.deps.vm.isReady()
|
||||
return { initialized: running, running }
|
||||
}
|
||||
|
||||
isHealthy(): Promise<boolean> {
|
||||
const hostPort = this.hostPort
|
||||
return fetchOk(`http://127.0.0.1:${hostPort}/healthz`)
|
||||
}
|
||||
|
||||
/** Public proxy for the readiness probe so callers don't need to
|
||||
* reach into the protected method. */
|
||||
isReady(): Promise<boolean> {
|
||||
return this.readinessProbe()
|
||||
}
|
||||
|
||||
// ── Service-facing compat surface ────────────────────────────────
|
||||
// These wrap inherited lifecycle methods using the legacy method
|
||||
// names OpenClawService still uses. Keeping them lets the service
|
||||
// swap from the legacy `ContainerRuntime` to this class with
|
||||
// minimal touch; a follow-up can rename the call sites to use
|
||||
// `executeAction(...)` directly and drop these wrappers.
|
||||
|
||||
/** Pre-pull the gateway image without starting the container. */
|
||||
async prewarmGatewayImage(onLog?: LogFn): Promise<void> {
|
||||
await this.executeAction({ type: 'install' }, { onLog })
|
||||
}
|
||||
|
||||
/** Start the gateway container with the runtime's own spec. */
|
||||
async startGateway(_unused?: unknown, onLog?: LogFn): Promise<void> {
|
||||
await this.executeAction({ type: 'start' }, { onLog })
|
||||
}
|
||||
|
||||
async stopGateway(): Promise<void> {
|
||||
await this.executeAction({ type: 'stop' })
|
||||
}
|
||||
|
||||
async restartGateway(_unused?: unknown, onLog?: LogFn): Promise<void> {
|
||||
await this.executeAction({ type: 'restart' }, { onLog })
|
||||
}
|
||||
|
||||
/** Poll readiness until ready or timeout. Returns whether ready. */
|
||||
async waitForReady(_hostPort?: number, timeoutMs = 30_000): Promise<boolean> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await this.readinessProbe()) return true
|
||||
await Bun.sleep(1000)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async getGatewayLogs(tail = 50): Promise<string[]> {
|
||||
return this.getLogs(tail)
|
||||
}
|
||||
|
||||
tailGatewayLogs(onLine: LogFn): () => void {
|
||||
return this.tailLogs(onLine)
|
||||
}
|
||||
|
||||
isGatewayCurrent(): Promise<boolean> {
|
||||
return this.isImageCurrent()
|
||||
}
|
||||
|
||||
/** Run a one-shot command in a `<name>-setup` sibling container. */
|
||||
async runGatewaySetupCommand(
|
||||
command: string[],
|
||||
_unused?: unknown,
|
||||
onLog?: LogFn,
|
||||
): Promise<number> {
|
||||
const argv = command[0] === 'node' ? command.slice(1) : command
|
||||
const result = await this.runOneShot(['node', ...argv], { onLog })
|
||||
return result.exitCode
|
||||
}
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────
|
||||
|
||||
private buildGatewayEnv(timezone: string): Record<string, string> {
|
||||
return {
|
||||
HOME: GATEWAY_CONTAINER_HOME,
|
||||
OPENCLAW_HOME: GATEWAY_CONTAINER_HOME,
|
||||
OPENCLAW_STATE_DIR: GATEWAY_STATE_DIR,
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
NODE_COMPILE_CACHE: '/var/tmp/openclaw-compile-cache',
|
||||
NODE_ENV: 'production',
|
||||
TZ: timezone,
|
||||
PATH: GATEWAY_PATH,
|
||||
NPM_CONFIG_PREFIX: GATEWAY_NPM_PREFIX,
|
||||
OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH: '1',
|
||||
}
|
||||
}
|
||||
|
||||
private translateHostPathToGuest(hostPath: string): string {
|
||||
const root = this.openclawConfig.openclawDir
|
||||
if (hostPath === root) return GUEST_OPENCLAW_HOME
|
||||
if (hostPath.startsWith(`${root}/`)) {
|
||||
return `${GUEST_OPENCLAW_HOME}${hostPath.slice(root.length)}`
|
||||
}
|
||||
// Fall back to the generic VM path translation. acpx-side callers
|
||||
// never pass paths outside openclawDir today, but the legacy
|
||||
// implementation tolerated it so we mirror the behaviour.
|
||||
return hostPath
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOk(url: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize an acpx session key into the form OpenClaw expects on
|
||||
* `--session`: must start with `agent:` and be alphanumeric/dash. */
|
||||
function normalizeBridgeSessionKey(sessionKey: string | null): string | null {
|
||||
if (!sessionKey) return null
|
||||
if (sessionKey.startsWith('agent:')) return sessionKey
|
||||
return `agent:main:${sessionKey.replace(/[^a-zA-Z0-9-]/g, '-')}`
|
||||
}
|
||||
|
||||
/** Prepare OpenClaw without BrowserOS SOUL/MEMORY or BrowserOS MCP. */
|
||||
export async function prepareOpenClawContext(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext> {
|
||||
const paths = resolveAgentRuntimePaths({
|
||||
browserosDir: input.browserosDir,
|
||||
agentId: input.agent.id,
|
||||
})
|
||||
await ensureUsableCwd(paths.effectiveCwd, true)
|
||||
return {
|
||||
cwd: paths.effectiveCwd,
|
||||
runtimeSessionKey: input.sessionKey,
|
||||
runPrompt: buildBrowserosAcpPrompt(
|
||||
OPENCLAW_BROWSEROS_ACP_INSTRUCTIONS,
|
||||
input.message,
|
||||
),
|
||||
commandEnv: {},
|
||||
commandIdentity: 'openclaw',
|
||||
useBrowserosMcp: false,
|
||||
openclawSessionKey: input.sessionKey,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Factory + wire-up ──────────────────────────────────────────────
|
||||
|
||||
export interface ConfigureOpenClawRuntimeOptions {
|
||||
resourcesDir?: string
|
||||
browserosDir?: string
|
||||
}
|
||||
|
||||
/** Build an OpenClawContainerRuntime with production deps and register
|
||||
* it. Idempotent — repeat calls return the already-registered runtime.
|
||||
* Constructs on every platform so service callers (and tests that
|
||||
* override `service.runtime` post-construction) work uniformly. The
|
||||
* descriptor's `platforms: ['darwin']` is the live signal for the UI
|
||||
* / adapter health, and `start()` itself fails at limactl-not-found
|
||||
* on non-darwin if anyone actually invokes it. */
|
||||
export function configureOpenClawRuntime(
|
||||
options: ConfigureOpenClawRuntimeOptions = {},
|
||||
): OpenClawContainerRuntime {
|
||||
const existing = getOpenClawRuntime()
|
||||
if (existing) return existing
|
||||
|
||||
const browserosDir = options.browserosDir ?? getBrowserosDir()
|
||||
const openclawDir = getOpenClawDir()
|
||||
const resourcesDir = options.resourcesDir ?? null
|
||||
// Resolve bundled paths optimistically — on platforms / CI runners
|
||||
// without Lima, fall back to the bare command names so construction
|
||||
// succeeds. Lifecycle ops will fail at spawn time with the same
|
||||
// "not on PATH" error, matching how the other runtimes degrade.
|
||||
const limactlPath = (() => {
|
||||
if (!resourcesDir) return 'limactl'
|
||||
try {
|
||||
return resolveBundledLimactl(resourcesDir)
|
||||
} catch (err) {
|
||||
logger.warn('OpenClaw bundled limactl unavailable; falling back', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return 'limactl'
|
||||
}
|
||||
})()
|
||||
const templatePath = (() => {
|
||||
if (!resourcesDir) return undefined
|
||||
try {
|
||||
return resolveBundledLimaTemplate(resourcesDir)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
const limaHome = getLimaHomeDir(browserosDir)
|
||||
|
||||
const vm = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
templatePath,
|
||||
browserosRoot: browserosDir,
|
||||
})
|
||||
const cli = new ContainerCli({ limactlPath, limaHome, vmName: VM_NAME })
|
||||
const loader = new ImageLoader(cli)
|
||||
|
||||
const runtime = new OpenClawContainerRuntime(
|
||||
{
|
||||
cli,
|
||||
loader,
|
||||
vm,
|
||||
limactlPath,
|
||||
limaHome,
|
||||
vmName: VM_NAME,
|
||||
lockDir: join(openclawDir, '.locks'),
|
||||
},
|
||||
{ browserosDir, openclawDir },
|
||||
)
|
||||
|
||||
getAgentRuntimeRegistry().register(runtime)
|
||||
logger.debug('OpenClawContainerRuntime registered', {
|
||||
image: runtime.descriptor.defaultImage,
|
||||
})
|
||||
return runtime
|
||||
}
|
||||
|
||||
export function getOpenClawRuntime(): OpenClawContainerRuntime | null {
|
||||
const r = getAgentRuntimeRegistry().get('openclaw')
|
||||
return r instanceof OpenClawContainerRuntime ? r : null
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { AgentRuntime } from './agent-runtime'
|
||||
|
||||
export class AgentRuntimeRegistry {
|
||||
private readonly runtimes = new Map<string, AgentRuntime>()
|
||||
|
||||
register(runtime: AgentRuntime): void {
|
||||
const id = runtime.descriptor.adapterId
|
||||
if (this.runtimes.has(id)) {
|
||||
throw new Error(`Runtime for adapter "${id}" is already registered`)
|
||||
}
|
||||
this.runtimes.set(id, runtime)
|
||||
}
|
||||
|
||||
get(adapterId: string): AgentRuntime | null {
|
||||
return this.runtimes.get(adapterId) ?? null
|
||||
}
|
||||
|
||||
list(): ReadonlyArray<AgentRuntime> {
|
||||
return Array.from(this.runtimes.values())
|
||||
}
|
||||
|
||||
unregister(adapterId: string): boolean {
|
||||
return this.runtimes.delete(adapterId)
|
||||
}
|
||||
}
|
||||
|
||||
let globalRegistry: AgentRuntimeRegistry | null = null
|
||||
|
||||
export function getAgentRuntimeRegistry(): AgentRuntimeRegistry {
|
||||
if (!globalRegistry) globalRegistry = new AgentRuntimeRegistry()
|
||||
return globalRegistry
|
||||
}
|
||||
|
||||
/** Test-only — production code never calls this. */
|
||||
export function resetAgentRuntimeRegistry(): void {
|
||||
globalRegistry = null
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Shared types for the AgentRuntime layer. Pure types — no behaviour
|
||||
* lives here.
|
||||
*/
|
||||
|
||||
import type { ExecSpec } from '../../container/managed'
|
||||
|
||||
export type Platform = NodeJS.Platform
|
||||
|
||||
export interface RuntimeDescriptor {
|
||||
/** Stable id matching `agent.adapter` for harness lookups. */
|
||||
adapterId: string
|
||||
/** Human-readable label for UI. */
|
||||
displayName: string
|
||||
/** Discriminator for runtime kind. UI components route on this. */
|
||||
kind: 'container' | 'host-process'
|
||||
/** Platforms where this runtime is supported (today: ['darwin']
|
||||
* for container kinds; varies for host kinds). */
|
||||
platforms: ReadonlyArray<Platform>
|
||||
}
|
||||
|
||||
export type RuntimeState =
|
||||
| 'unsupported_platform'
|
||||
| 'errored'
|
||||
// container-only
|
||||
| 'not_installed'
|
||||
| 'installing'
|
||||
| 'installed'
|
||||
| 'starting'
|
||||
| 'running'
|
||||
| 'stopped'
|
||||
// host-only
|
||||
| 'cli_missing'
|
||||
| 'cli_present'
|
||||
| 'cli_unhealthy'
|
||||
|
||||
export interface RuntimeStatusSnapshot {
|
||||
adapterId: string
|
||||
state: RuntimeState
|
||||
/** True iff the harness can spawn turns against this runtime now. */
|
||||
isReady: boolean
|
||||
lastError: string | null
|
||||
lastErrorAt: number | null
|
||||
/** Wall-clock ms when the last definitive readiness probe completed.
|
||||
* Null when the runtime has never been probed. Distinct from
|
||||
* `lastErrorAt` (only set on errors) so consumers can read probe
|
||||
* staleness regardless of health state. */
|
||||
probedAt?: number | null
|
||||
/** Adapter-specific structured fields the UI may render. Keep keys
|
||||
* stable so the UI can opt into them. */
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type RuntimeCapability =
|
||||
| 'install'
|
||||
| 'start'
|
||||
| 'stop'
|
||||
| 'restart'
|
||||
| 'reset-soft'
|
||||
| 'reset-wipe-agent'
|
||||
| 'reset-hard'
|
||||
| 'logs'
|
||||
| 'terminal'
|
||||
| 'reinstall-cli'
|
||||
| 'check-auth'
|
||||
| 'gateway-control-plane'
|
||||
| 'agent-crud-via-runtime'
|
||||
|
||||
/**
|
||||
* Discriminated union of every action a runtime can be asked to
|
||||
* perform. Required arguments live on the variant so callers can't
|
||||
* forget them (e.g. `agentId` for `reset-wipe-agent`).
|
||||
*/
|
||||
export type RuntimeAction =
|
||||
| { type: 'install' }
|
||||
| { type: 'start' }
|
||||
| { type: 'stop' }
|
||||
| { type: 'restart' }
|
||||
| { type: 'reset-soft' }
|
||||
| { type: 'reset-wipe-agent'; agentId: string }
|
||||
| { type: 'reset-hard' }
|
||||
| { type: 'reinstall-cli' }
|
||||
| { type: 'check-auth' }
|
||||
|
||||
export type StateListener = (snapshot: RuntimeStatusSnapshot) => void
|
||||
export type Unsubscribe = () => void
|
||||
|
||||
export type { ExecSpec }
|
||||
@@ -27,6 +27,20 @@ export interface AgentHistoryPage {
|
||||
items: AgentHistoryEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* One file the harness attributed to the assistant turn that just
|
||||
* finished. Emitted as part of a `produced_files` event before the
|
||||
* terminal `done` so the inline artifact card renders alongside the
|
||||
* streamed text the user just watched complete.
|
||||
*/
|
||||
export interface ProducedFileEventEntry {
|
||||
id: string
|
||||
/** Workspace-relative POSIX path. */
|
||||
path: string
|
||||
size: number
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
export type AgentStreamEvent =
|
||||
| {
|
||||
type: 'text_delta'
|
||||
@@ -47,6 +61,10 @@ export type AgentStreamEvent =
|
||||
text: string
|
||||
rawType?: string
|
||||
}
|
||||
| {
|
||||
type: 'produced_files'
|
||||
files: ProducedFileEventEntry[]
|
||||
}
|
||||
| {
|
||||
type: 'done'
|
||||
text?: string
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ContainerCliError,
|
||||
ContainerNameInUseError,
|
||||
ContainerNameReleaseTimeoutError,
|
||||
ContainerNotRunningError,
|
||||
} from '../vm/errors'
|
||||
import { LimaCli } from '../vm/lima-cli'
|
||||
import type {
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
MountSpec,
|
||||
PortMapping,
|
||||
WaitForContainerNameReleaseOptions,
|
||||
WaitForContainerRunningOptions,
|
||||
} from './types'
|
||||
|
||||
export function buildNerdctlCommand(args: string[]): string[] {
|
||||
@@ -117,6 +119,36 @@ export class ContainerCli {
|
||||
throw this.commandError(args, result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll `nerdctl container inspect` until the container reports
|
||||
* `running: true`. Used by the managed-container layer between
|
||||
* `nerdctl create + start` and "container is ready for `exec`" so
|
||||
* the harness never spawns a turn against a half-started container.
|
||||
*
|
||||
* Distinct from `waitForContainerNameRelease`, which waits for the
|
||||
* container to disappear after `rm`. Defaults are sized for a
|
||||
* cold-start: 30 s budget at 500 ms cadence (60 polls). Caller can
|
||||
* tighten for tests via `opts`.
|
||||
*/
|
||||
async waitForContainerRunning(
|
||||
name: string,
|
||||
opts: WaitForContainerRunningOptions = {},
|
||||
): Promise<void> {
|
||||
const timeoutMs = opts.timeoutMs ?? 30_000
|
||||
const intervalMs = opts.intervalMs ?? 500
|
||||
const startedAt = Date.now()
|
||||
|
||||
while (Date.now() - startedAt <= timeoutMs) {
|
||||
const info = await this.inspectContainer(name)
|
||||
if (info?.running === true) return
|
||||
const remainingMs = timeoutMs - (Date.now() - startedAt)
|
||||
if (remainingMs <= 0) break
|
||||
await Bun.sleep(Math.min(intervalMs, remainingMs))
|
||||
}
|
||||
|
||||
throw new ContainerNotRunningError(name, timeoutMs)
|
||||
}
|
||||
|
||||
/** Wait for containerd/nerdctl to stop resolving a container name after rm. */
|
||||
async waitForContainerNameRelease(
|
||||
name: string,
|
||||
@@ -235,6 +267,7 @@ function buildCreateArgs(spec: ContainerSpec): string[] {
|
||||
args.push('--health-retries', String(spec.health.retries))
|
||||
}
|
||||
}
|
||||
if (spec.entrypoint) args.push('--entrypoint', spec.entrypoint)
|
||||
|
||||
args.push(spec.image)
|
||||
args.push(...(spec.command ?? []))
|
||||
@@ -285,7 +318,7 @@ function parseContainerInfo(
|
||||
}
|
||||
}
|
||||
|
||||
function isNoSuchContainer(stderr: string): boolean {
|
||||
export function isNoSuchContainer(stderr: string): boolean {
|
||||
const lower = stderr.toLowerCase()
|
||||
return (
|
||||
lower.includes('no such container') || lower.includes('container not found')
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import {
|
||||
HERMES_AGENT_NAME,
|
||||
HERMES_IMAGE,
|
||||
} from '@browseros/shared/constants/hermes'
|
||||
import {
|
||||
OPENCLAW_AGENT_NAME,
|
||||
OPENCLAW_IMAGE,
|
||||
@@ -35,10 +39,14 @@ export class ImageLoader {
|
||||
|
||||
/** Resolve BrowserOS agent names to image refs and ensure the image exists. */
|
||||
async ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise<string> {
|
||||
if (name !== OPENCLAW_AGENT_NAME) {
|
||||
throw new ImageLoadError(name, `no agent image mapping: ${name}`)
|
||||
if (name === OPENCLAW_AGENT_NAME) {
|
||||
await this.ensureImageLoaded(OPENCLAW_IMAGE, onLog)
|
||||
return OPENCLAW_IMAGE
|
||||
}
|
||||
await this.ensureImageLoaded(OPENCLAW_IMAGE, onLog)
|
||||
return OPENCLAW_IMAGE
|
||||
if (name === HERMES_AGENT_NAME) {
|
||||
await this.ensureImageLoaded(HERMES_IMAGE, onLog)
|
||||
return HERMES_IMAGE
|
||||
}
|
||||
throw new ImageLoadError(name, `no agent image mapping: ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@
|
||||
|
||||
export * from './container-cli'
|
||||
export * from './image-loader'
|
||||
export * from './managed'
|
||||
export * from './types'
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { ContainerState } from './types'
|
||||
|
||||
/**
|
||||
* Thrown by `ManagedContainer.execProcess` / `execOneShot` when the
|
||||
* container isn't in `running` state and can't be made ready in time
|
||||
* (or shouldn't be — `errored` and `not_installed` are terminal until
|
||||
* the user takes action). The harness route layer catches this and
|
||||
* surfaces a structured error frame on the chat SSE so the UI can
|
||||
* render an actionable banner instead of a silent broken turn.
|
||||
*
|
||||
* `reason` discriminates so the UI can pick the right next-action:
|
||||
* - `not_installed` → "Set up containers"
|
||||
* - `installing` / `starting` → "Container is starting, please wait"
|
||||
* - `stopped` → "Start container"
|
||||
* - `errored` → "Reset container"
|
||||
* - `timeout` → "Container is taking too long, try again"
|
||||
*/
|
||||
export class ContainerNotReadyError extends Error {
|
||||
constructor(
|
||||
public readonly state: ContainerState,
|
||||
public readonly containerId: string,
|
||||
public readonly reason:
|
||||
| 'not_installed'
|
||||
| 'installing'
|
||||
| 'starting'
|
||||
| 'stopped'
|
||||
| 'errored'
|
||||
| 'timeout',
|
||||
public readonly hint: string,
|
||||
public readonly lastError?: string | null,
|
||||
) {
|
||||
super(
|
||||
`Container "${containerId}" is not ready (state=${state}, reason=${reason}): ${hint}` +
|
||||
(lastError ? ` — last error: ${lastError}` : ''),
|
||||
)
|
||||
this.name = 'ContainerNotReadyError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when a `reset(level)` arrives with a level that the
|
||||
* current `ManagedContainer` implementation doesn't support yet
|
||||
* (e.g. `'hard'` requires a `VmService` that hasn't shipped). */
|
||||
export class ResetNotSupportedError extends Error {
|
||||
constructor(
|
||||
public readonly level: string,
|
||||
message = `reset(${level}) is not supported in this build`,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ResetNotSupportedError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown by `toContainerPath` / `toHostPath` when the input path
|
||||
* doesn't sit under any declared mount root. */
|
||||
export class PathOutsideMountsError extends Error {
|
||||
constructor(
|
||||
public readonly path: string,
|
||||
public readonly direction: 'host->container' | 'container->host',
|
||||
) {
|
||||
super(
|
||||
`Path "${path}" is not inside any declared mount root for direction ${direction}`,
|
||||
)
|
||||
this.name = 'PathOutsideMountsError'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export {
|
||||
ContainerNotReadyError,
|
||||
PathOutsideMountsError,
|
||||
ResetNotSupportedError,
|
||||
} from './errors'
|
||||
export {
|
||||
ManagedContainer,
|
||||
type ManagedContainerDeps,
|
||||
type StateListener,
|
||||
type Unsubscribe,
|
||||
} from './managed-container'
|
||||
export type {
|
||||
ContainerDescriptor,
|
||||
ContainerState,
|
||||
ContainerStatusSnapshot,
|
||||
ExecResult,
|
||||
ExecSpec,
|
||||
MountRoot,
|
||||
Platform,
|
||||
ResetLevel,
|
||||
ResetOptions,
|
||||
} from './types'
|
||||
export { TRANSIENT_STATES } from './types'
|
||||
@@ -0,0 +1,681 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Abstract base for every container-backed agent adapter. Owns the
|
||||
* state machine, lifecycle lock, and the gated `execute*` family.
|
||||
* Subclasses provide image / spec / readiness probe / mount roots —
|
||||
* see `./types.ts` for the contract.
|
||||
*
|
||||
* Layering: this lives at the container-orchestration boundary.
|
||||
* Anything above (harness, ACP runtime, HTTP routes, UI) talks to
|
||||
* the abstraction; anything below (`ContainerCli`, `VmRuntime`,
|
||||
* `ImageLoader`) is plumbed through the constructor. The ACP layer
|
||||
* never imports `limactl`, `LIMA_HOME`, or container names again —
|
||||
* `buildExecArgv` is the single owner of that string.
|
||||
*/
|
||||
|
||||
import { logger } from '../../logger'
|
||||
import { withProcessLock } from '../../process-lock'
|
||||
import { ContainerNameInUseError } from '../../vm/errors'
|
||||
import type { VmRuntime } from '../../vm/vm-runtime'
|
||||
import { type ContainerCli, isNoSuchContainer } from '../container-cli'
|
||||
import type { ImageLoader } from '../image-loader'
|
||||
import type { ContainerSpec, LogFn } from '../types'
|
||||
import {
|
||||
ContainerNotReadyError,
|
||||
PathOutsideMountsError,
|
||||
ResetNotSupportedError,
|
||||
} from './errors'
|
||||
import type {
|
||||
ContainerDescriptor,
|
||||
ContainerState,
|
||||
ContainerStatusSnapshot,
|
||||
ExecResult,
|
||||
ExecSpec,
|
||||
MountRoot,
|
||||
ResetLevel,
|
||||
ResetOptions,
|
||||
} from './types'
|
||||
|
||||
export interface ManagedContainerDeps {
|
||||
cli: ContainerCli
|
||||
loader: ImageLoader
|
||||
vm: VmRuntime
|
||||
/** Absolute path to the bundled `limactl`. */
|
||||
limactlPath: string
|
||||
/** `LIMA_HOME` env value to scope limactl to BrowserOS's VM dir. */
|
||||
limaHome: string
|
||||
/** Lima VM name (today: a single shared VM). */
|
||||
vmName: string
|
||||
/** Process-lock dir for serialising lifecycle ops across processes. */
|
||||
lockDir: string
|
||||
}
|
||||
|
||||
export type StateListener = (state: ContainerState) => void
|
||||
export type Unsubscribe = () => void
|
||||
|
||||
/** Default budget for `execProcess` / `execOneShot` to wait through
|
||||
* a `starting` state. After this, throws `ContainerNotReadyError`
|
||||
* with `reason: 'timeout'`. */
|
||||
const DEFAULT_EXEC_GATE_TIMEOUT_MS = 60_000
|
||||
|
||||
export abstract class ManagedContainer {
|
||||
// ── Subclass contract ───────────────────────────────────────────
|
||||
abstract readonly descriptor: ContainerDescriptor
|
||||
protected abstract buildContainerSpec(): Promise<ContainerSpec>
|
||||
protected abstract readinessProbe(): Promise<boolean>
|
||||
protected abstract mountRoots(): readonly MountRoot[]
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────
|
||||
protected state: ContainerState = 'not_installed'
|
||||
protected lastError: string | null = null
|
||||
protected lastErrorAt: number | null = null
|
||||
private listeners = new Set<StateListener>()
|
||||
|
||||
// Promise chain so concurrent lifecycle calls serialise within this
|
||||
// process. Cross-process serialisation lives in `withProcessLock`
|
||||
// below.
|
||||
private lifecycleLock: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(protected readonly deps: ManagedContainerDeps) {}
|
||||
|
||||
// ── State surface ───────────────────────────────────────────────
|
||||
|
||||
getState(): ContainerState {
|
||||
return this.state
|
||||
}
|
||||
|
||||
/** Returns an unsubscribe handle. Listeners fire on every transition. */
|
||||
subscribeState(listener: StateListener): Unsubscribe {
|
||||
this.listeners.add(listener)
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
getStatusSnapshot(): ContainerStatusSnapshot {
|
||||
return {
|
||||
adapterId: this.descriptor.adapterId,
|
||||
containerName: this.descriptor.containerName,
|
||||
state: this.state,
|
||||
lastError: this.lastError,
|
||||
lastErrorAt: this.lastErrorAt,
|
||||
}
|
||||
}
|
||||
|
||||
protected setState(next: ContainerState, errorMessage?: string): void {
|
||||
if (next === this.state && !errorMessage) return
|
||||
const prev = this.state
|
||||
this.state = next
|
||||
if (errorMessage !== undefined) {
|
||||
this.lastError = errorMessage
|
||||
this.lastErrorAt = Date.now()
|
||||
} else if (next === 'running') {
|
||||
// Successful reach to running clears the last error.
|
||||
this.lastError = null
|
||||
this.lastErrorAt = null
|
||||
}
|
||||
logger.debug('ManagedContainer state transition', {
|
||||
adapterId: this.descriptor.adapterId,
|
||||
from: prev,
|
||||
to: next,
|
||||
error: errorMessage,
|
||||
})
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(next)
|
||||
} catch (err) {
|
||||
// Listener bugs must not derail the state machine.
|
||||
logger.warn('ManagedContainer state listener threw', {
|
||||
adapterId: this.descriptor.adapterId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pull the image into the VM's containerd store. Idempotent — if
|
||||
* the image is already present, transitions through `installing →
|
||||
* installed` immediately.
|
||||
*/
|
||||
async install(opts: { onLog?: (msg: string) => void } = {}): Promise<void> {
|
||||
return this.withLifecycleLock('install', async () => {
|
||||
if (this.state === 'running' || this.state === 'starting') return
|
||||
try {
|
||||
// Image ops run inside the Lima VM, so the VM has to be up
|
||||
// before nerdctl can pull. On cold boot this method is the
|
||||
// first lifecycle call to win the lock, so the ensure has to
|
||||
// happen here too — `start()` having its own ensure is not
|
||||
// enough.
|
||||
await this.deps.vm.ensureReady(opts.onLog)
|
||||
this.setState('installing')
|
||||
await this.deps.loader.ensureImageLoaded(
|
||||
this.descriptor.defaultImage,
|
||||
opts.onLog,
|
||||
)
|
||||
this.setState('installed')
|
||||
} catch (err) {
|
||||
this.setState(
|
||||
'errored',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
throw err
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the container to `running`. Encapsulates: ensure VM ready,
|
||||
* ensure image, recreate container from current spec, start it,
|
||||
* wait for the daemon to report `running`, run the readiness
|
||||
* probe. Each phase transitions state so subscribers see progress.
|
||||
*/
|
||||
async start(opts: { onLog?: (msg: string) => void } = {}): Promise<void> {
|
||||
return this.withLifecycleLock('start', async () => {
|
||||
if (this.state === 'running') return
|
||||
const log = (msg: string) => {
|
||||
logger.info(msg, { adapterId: this.descriptor.adapterId })
|
||||
opts.onLog?.(msg)
|
||||
}
|
||||
try {
|
||||
await this.deps.vm.ensureReady(log)
|
||||
this.setState('installing')
|
||||
await this.deps.loader.ensureImageLoaded(
|
||||
this.descriptor.defaultImage,
|
||||
log,
|
||||
)
|
||||
this.setState('starting')
|
||||
const spec = await this.buildContainerSpec()
|
||||
// Always recreate so spec changes take effect on restart. The
|
||||
// existing container (if any) is force-removed first.
|
||||
await this.deps.cli.removeContainer(spec.name, { force: true })
|
||||
await this.deps.cli.waitForContainerNameRelease(spec.name, {
|
||||
timeoutMs: 10_000,
|
||||
intervalMs: 100,
|
||||
})
|
||||
await this.deps.cli.createContainer(spec, log)
|
||||
await this.deps.cli.startContainer(spec.name, log)
|
||||
const probeOpts = this.descriptor.readinessProbe
|
||||
await this.deps.cli.waitForContainerRunning(spec.name, {
|
||||
timeoutMs: probeOpts?.timeoutMs ?? 30_000,
|
||||
intervalMs: probeOpts?.intervalMs ?? 500,
|
||||
})
|
||||
// Run the subclass-defined probe — usually a `--version` exec
|
||||
// or HTTP /readyz call. Failing this is errored, not stopped.
|
||||
const probeOk = await this.readinessProbe()
|
||||
if (!probeOk) {
|
||||
this.setState(
|
||||
'errored',
|
||||
'Readiness probe failed after container reached running state',
|
||||
)
|
||||
throw new Error(`${this.descriptor.adapterId} readiness probe failed`)
|
||||
}
|
||||
this.setState('running')
|
||||
} catch (err) {
|
||||
if (this.state !== 'errored') {
|
||||
this.setState(
|
||||
'errored',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Stop and remove the container. Image and per-agent data preserved. */
|
||||
async stop(): Promise<void> {
|
||||
return this.withLifecycleLock('stop', async () => {
|
||||
try {
|
||||
await this.deps.cli.removeContainer(this.descriptor.containerName, {
|
||||
force: true,
|
||||
})
|
||||
// Stop is forward-friendly even from `errored` — the user is
|
||||
// explicitly asking for a clean state.
|
||||
this.setState('stopped')
|
||||
} catch (err) {
|
||||
// A stop failure is mostly cosmetic; we still want stopped to
|
||||
// be the user's reality. Log and recover.
|
||||
logger.warn('ManagedContainer stop failed', {
|
||||
adapterId: this.descriptor.adapterId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
this.setState('stopped')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async restart(opts: { onLog?: (msg: string) => void } = {}): Promise<void> {
|
||||
await this.stop()
|
||||
await this.start(opts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset is intentionally a stub here — it pins the API shape so the
|
||||
* follow-up PR can land without revving the abstract class. The
|
||||
* follow-up will implement `'soft'` (= restart), `'wipe-agent'`
|
||||
* (= soft + `rm -rf` per-agent home dir), and `'hard'` (delegated
|
||||
* to a `VmService` that hasn't shipped yet).
|
||||
*/
|
||||
async reset(level: ResetLevel, _opts: ResetOptions = {}): Promise<void> {
|
||||
throw new ResetNotSupportedError(
|
||||
level,
|
||||
`reset(${level}) is not implemented yet — wired in a follow-up PR`,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Image / logs ────────────────────────────────────────────────
|
||||
|
||||
/** True iff the existing container's image matches `descriptor.defaultImage`; treats SHA-pinned variants as a match. */
|
||||
async isImageCurrent(): Promise<boolean> {
|
||||
const actual = await this.deps.cli.containerImageRef(
|
||||
this.descriptor.containerName,
|
||||
)
|
||||
if (!actual) return false
|
||||
const expected = this.descriptor.defaultImage
|
||||
return actual === expected || actual.startsWith(`${expected}@sha256:`)
|
||||
}
|
||||
|
||||
/** Tail the last `n` log lines; returns [] if the container is missing, throws on other CLI failures. */
|
||||
async getLogs(tail = 50): Promise<string[]> {
|
||||
const lines: string[] = []
|
||||
const args = ['logs', '-n', String(tail), this.descriptor.containerName]
|
||||
const result = await this.deps.cli.runCommand(args, (line) =>
|
||||
lines.push(line),
|
||||
)
|
||||
if (result.exitCode === 0) return lines
|
||||
if (isNoSuchContainer(result.stderr)) return []
|
||||
throw new Error(
|
||||
`nerdctl ${args.join(' ')} exited ${result.exitCode}: ${result.stderr.trim() || '(no stderr)'}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Stream container logs until the returned handle is invoked. */
|
||||
tailLogs(onLine: LogFn): () => void {
|
||||
return this.deps.cli.tailLogs(this.descriptor.containerName, onLine)
|
||||
}
|
||||
|
||||
// ── Sibling-container one-shot ──────────────────────────────────
|
||||
|
||||
/** Run `argv` in a throwaway `<name>-setup` sibling using the same image+mounts; force-removes after, including on throw or timeout. */
|
||||
async runOneShot(
|
||||
argv: ReadonlyArray<string>,
|
||||
opts: {
|
||||
onLog?: LogFn
|
||||
env?: Record<string, string>
|
||||
processTimeoutMs?: number
|
||||
} = {},
|
||||
): Promise<ExecResult> {
|
||||
return this.withLifecycleLock('run-one-shot', async () => {
|
||||
const setupName = `${this.descriptor.containerName}-setup`
|
||||
const liveSpec = await this.buildContainerSpec()
|
||||
const setupSpec: ContainerSpec = {
|
||||
...liveSpec,
|
||||
name: setupName,
|
||||
restart: 'no',
|
||||
ports: undefined,
|
||||
health: undefined,
|
||||
env: { ...liveSpec.env, ...opts.env },
|
||||
command: [...argv] as [string, ...string[]],
|
||||
}
|
||||
try {
|
||||
await this.createOneShotContainer(setupSpec, opts.onLog)
|
||||
const result = await this.runWithOptionalTimeout(
|
||||
['start', '-a', setupName],
|
||||
opts.onLog,
|
||||
opts.processTimeoutMs,
|
||||
)
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
}
|
||||
} finally {
|
||||
await this.deps.cli.removeContainer(
|
||||
setupName,
|
||||
{ force: true },
|
||||
opts.onLog,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** nerdctl occasionally lags releasing a name after `rm`; retry to absorb that. */
|
||||
private async createOneShotContainer(
|
||||
spec: ContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
const maxAttempts = 3
|
||||
let attempt = 1
|
||||
while (true) {
|
||||
await this.deps.cli.removeContainer(spec.name, { force: true }, onLog)
|
||||
await this.deps.cli.waitForContainerNameRelease(spec.name, {
|
||||
timeoutMs: 10_000,
|
||||
intervalMs: 100,
|
||||
})
|
||||
try {
|
||||
await this.deps.cli.createContainer(spec, onLog)
|
||||
return
|
||||
} catch (err) {
|
||||
if (
|
||||
!(err instanceof ContainerNameInUseError) ||
|
||||
attempt >= maxAttempts
|
||||
) {
|
||||
throw err
|
||||
}
|
||||
logger.warn('One-shot container name still in use; retrying', {
|
||||
adapterId: this.descriptor.adapterId,
|
||||
containerName: spec.name,
|
||||
attempt,
|
||||
maxAttempts,
|
||||
})
|
||||
attempt += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runWithOptionalTimeout(
|
||||
args: string[],
|
||||
onLog: LogFn | undefined,
|
||||
timeoutMs: number | undefined,
|
||||
): Promise<ExecResult> {
|
||||
if (timeoutMs === undefined) return this.deps.cli.runCommand(args, onLog)
|
||||
// The underlying runCommand keeps streaming onLog after a timeout
|
||||
// until removeContainer kills `nerdctl start -a`. Gate the wrapped
|
||||
// callback so callers don't see stale lines after rejection.
|
||||
let active = true
|
||||
const guardedOnLog: LogFn | undefined = onLog
|
||||
? (line) => {
|
||||
if (active) onLog(line)
|
||||
}
|
||||
: undefined
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const timeoutPromise = new Promise<ExecResult>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
active = false
|
||||
reject(
|
||||
new Error(
|
||||
`One-shot exceeded timeout of ${timeoutMs}ms for ${args.join(' ')}`,
|
||||
),
|
||||
)
|
||||
}, timeoutMs)
|
||||
})
|
||||
try {
|
||||
return await Promise.race([
|
||||
this.deps.cli.runCommand(args, guardedOnLog),
|
||||
timeoutPromise,
|
||||
])
|
||||
} finally {
|
||||
if (timer !== null) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Execute family ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the shell-command string acpx-core would spawn for an
|
||||
* `ExecSpec`. Pure builder; does NOT gate on state. Callers that
|
||||
* need state gating (i.e. anyone other than acpx-core which has
|
||||
* its own scheduling) should use `execProcess` / `execOneShot`.
|
||||
*
|
||||
* Returned string is intended for `sh -c …` consumption — see
|
||||
* `acpx-runtime.ts` for the historical context.
|
||||
*/
|
||||
buildExecArgv(spec: ExecSpec): string {
|
||||
return this.buildExecArgvArray(spec).join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a long-lived child process inside the container, gated on
|
||||
* `state === 'running'`. Waits through `starting` up to
|
||||
* `execGateTimeoutMs` (default 60s). Returns Bun's spawned process
|
||||
* — caller owns stdio piping and `exit`.
|
||||
*
|
||||
* The ACP runtime continues to use `buildExecArgv` directly
|
||||
* because acpx-core does its own spawn; this entry point exists
|
||||
* for `execOneShot` and for callers that want to own stdio.
|
||||
*/
|
||||
async execProcess(
|
||||
spec: ExecSpec,
|
||||
opts: { execGateTimeoutMs?: number } = {},
|
||||
): Promise<Bun.Subprocess<'pipe', 'pipe', 'pipe'>> {
|
||||
await this.waitForRunning(
|
||||
opts.execGateTimeoutMs ?? DEFAULT_EXEC_GATE_TIMEOUT_MS,
|
||||
)
|
||||
const argv = this.buildExecArgvArray(spec)
|
||||
return Bun.spawn(argv, {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper for short-lived commands the harness or
|
||||
* control-panel might need (cleanup, version checks, diagnostics).
|
||||
* Buffers stdio and returns once the process exits.
|
||||
*/
|
||||
async execOneShot(
|
||||
spec: ExecSpec,
|
||||
opts: { execGateTimeoutMs?: number; processTimeoutMs?: number } = {},
|
||||
): Promise<ExecResult> {
|
||||
const proc = await this.execProcess(spec, {
|
||||
execGateTimeoutMs: opts.execGateTimeoutMs,
|
||||
})
|
||||
if (opts.processTimeoutMs !== undefined) {
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
proc.kill()
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}, opts.processTimeoutMs)
|
||||
proc.exited.finally(() => clearTimeout(timer))
|
||||
}
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
])
|
||||
return { exitCode, stdout, stderr }
|
||||
}
|
||||
|
||||
// ── Filesystem ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Translate a host path under one of the declared mount roots into
|
||||
* the path the container sees. Throws `PathOutsideMountsError` for
|
||||
* paths that don't sit under any mount.
|
||||
*/
|
||||
toContainerPath(hostPath: string): string {
|
||||
const normalized = normalizePath(hostPath)
|
||||
for (const mount of this.mountRoots()) {
|
||||
const root = normalizePath(mount.hostPath)
|
||||
if (isPathInside(root, normalized)) {
|
||||
const suffix = normalized.slice(root.length)
|
||||
return joinContainerPath(mount.containerPath, suffix)
|
||||
}
|
||||
}
|
||||
throw new PathOutsideMountsError(hostPath, 'host->container')
|
||||
}
|
||||
|
||||
/** Inverse of `toContainerPath`. */
|
||||
toHostPath(containerPath: string): string {
|
||||
const normalized = normalizePath(containerPath)
|
||||
for (const mount of this.mountRoots()) {
|
||||
const root = normalizePath(mount.containerPath)
|
||||
if (isPathInside(root, normalized)) {
|
||||
const suffix = normalized.slice(root.length)
|
||||
return joinContainerPath(mount.hostPath, suffix)
|
||||
}
|
||||
}
|
||||
throw new PathOutsideMountsError(containerPath, 'container->host')
|
||||
}
|
||||
|
||||
// ── Internals ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the argv array we'd hand to `Bun.spawn` to run `spec`
|
||||
* inside the container. Pure — no state side effects. Single
|
||||
* source of truth for the limactl/nerdctl chain so the ACP layer
|
||||
* never has to know about it (closes the §4.1 leak from the
|
||||
* audit).
|
||||
*/
|
||||
private buildExecArgvArray(spec: ExecSpec): string[] {
|
||||
const argv = [
|
||||
'env',
|
||||
`LIMA_HOME=${this.deps.limaHome}`,
|
||||
this.deps.limactlPath,
|
||||
'shell',
|
||||
'--workdir',
|
||||
'/',
|
||||
this.deps.vmName,
|
||||
'--',
|
||||
'nerdctl',
|
||||
'exec',
|
||||
'-i',
|
||||
]
|
||||
for (const [key, value] of Object.entries(spec.env ?? {})) {
|
||||
argv.push('-e', `${key}=${value}`)
|
||||
}
|
||||
argv.push(this.descriptor.containerName, ...spec.argv)
|
||||
return argv
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve once the container is `running`, or throw a typed
|
||||
* `ContainerNotReadyError`. `installing` / `starting` callers wait
|
||||
* up to `timeoutMs`. `not_installed` / `stopped` / `errored` reject
|
||||
* immediately — caller is expected to call `install()` / `start()`
|
||||
* / `reset()` first. Used by `execProcess`.
|
||||
*/
|
||||
protected async waitForRunning(timeoutMs: number): Promise<void> {
|
||||
if (this.state === 'running') return
|
||||
if (this.state === 'not_installed') {
|
||||
throw new ContainerNotReadyError(
|
||||
this.state,
|
||||
this.descriptor.containerName,
|
||||
'not_installed',
|
||||
'Container image has not been pulled. Call install() first.',
|
||||
this.lastError,
|
||||
)
|
||||
}
|
||||
if (this.state === 'stopped') {
|
||||
throw new ContainerNotReadyError(
|
||||
this.state,
|
||||
this.descriptor.containerName,
|
||||
'stopped',
|
||||
'Container is stopped. Call start() first.',
|
||||
this.lastError,
|
||||
)
|
||||
}
|
||||
if (this.state === 'errored') {
|
||||
throw new ContainerNotReadyError(
|
||||
this.state,
|
||||
this.descriptor.containerName,
|
||||
'errored',
|
||||
'Container has hit a terminal error. Reset to recover.',
|
||||
this.lastError,
|
||||
)
|
||||
}
|
||||
|
||||
// 'installing' or 'starting' — wait for either 'running' or
|
||||
// 'errored', whichever comes first, with a timeout.
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let settled = false
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
unsubscribe()
|
||||
reject(
|
||||
new ContainerNotReadyError(
|
||||
this.state,
|
||||
this.descriptor.containerName,
|
||||
'timeout',
|
||||
`Timed out after ${timeoutMs}ms waiting for container to reach running state.`,
|
||||
this.lastError,
|
||||
),
|
||||
)
|
||||
}, timeoutMs)
|
||||
const unsubscribe = this.subscribeState((s) => {
|
||||
if (settled) return
|
||||
if (s === 'running') {
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
unsubscribe()
|
||||
resolve()
|
||||
} else if (s === 'errored') {
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
unsubscribe()
|
||||
reject(
|
||||
new ContainerNotReadyError(
|
||||
'errored',
|
||||
this.descriptor.containerName,
|
||||
'errored',
|
||||
'Container hit a terminal error while we were waiting.',
|
||||
this.lastError,
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise lifecycle operations both within this process (via the
|
||||
* promise chain) and across processes (via file-lock).
|
||||
*/
|
||||
private async withLifecycleLock<T>(
|
||||
operation: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previous = this.lifecycleLock
|
||||
let release!: () => void
|
||||
this.lifecycleLock = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
await previous.catch(() => undefined)
|
||||
try {
|
||||
return await withProcessLock(
|
||||
`${this.descriptor.adapterId}-lifecycle`,
|
||||
{ lockDir: this.deps.lockDir },
|
||||
async () => {
|
||||
logger.debug('ManagedContainer lifecycle op started', {
|
||||
adapterId: this.descriptor.adapterId,
|
||||
operation,
|
||||
})
|
||||
return await fn()
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Path helpers (kept private to this module) ──────────────────────
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
// Strip trailing slashes, collapse repeats. Leave leading slash
|
||||
// alone so absolute paths stay absolute.
|
||||
let out = p.replace(/\/+/g, '/')
|
||||
if (out.length > 1 && out.endsWith('/')) out = out.slice(0, -1)
|
||||
return out
|
||||
}
|
||||
|
||||
function isPathInside(root: string, candidate: string): boolean {
|
||||
if (candidate === root) return true
|
||||
return candidate.startsWith(`${root}/`)
|
||||
}
|
||||
|
||||
function joinContainerPath(base: string, suffix: string): string {
|
||||
if (!suffix) return base
|
||||
if (suffix.startsWith('/')) return `${base}${suffix}`
|
||||
return `${base}/${suffix}`
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Types shared by the abstract `ManagedContainer` base and its
|
||||
* subclasses. Kept in their own file so the base class file can stay
|
||||
* focused on behaviour.
|
||||
*/
|
||||
|
||||
/** Subset of `NodeJS.Platform` — kept loose so a future Linux add
|
||||
* doesn't require touching the abstraction. */
|
||||
export type Platform = NodeJS.Platform
|
||||
|
||||
/** State the container is in, viewed from the harness's perspective. */
|
||||
export type ContainerState =
|
||||
| 'not_installed'
|
||||
| 'installing'
|
||||
| 'installed'
|
||||
| 'starting'
|
||||
| 'running'
|
||||
| 'stopped'
|
||||
| 'errored'
|
||||
|
||||
/** Subset of the state machine that callers should treat as "may
|
||||
* resolve to running on its own with time" — `execProcess` and
|
||||
* friends wait through these. Callers checking `isReady()` use this. */
|
||||
export const TRANSIENT_STATES: ReadonlySet<ContainerState> = new Set([
|
||||
'installing',
|
||||
'starting',
|
||||
])
|
||||
|
||||
export interface ContainerDescriptor {
|
||||
/** Stable id matching `agent.adapter` for harness lookups. */
|
||||
adapterId: string
|
||||
/** Human-readable label for UI. */
|
||||
displayName: string
|
||||
/** Image ref to pull on `install()`. */
|
||||
defaultImage: string
|
||||
/** Fixed container name inside the VM. */
|
||||
containerName: string
|
||||
/** Platforms where this container is supported (today: ['darwin']). */
|
||||
platforms: ReadonlyArray<Platform>
|
||||
/** Optional probe tuning. */
|
||||
readinessProbe?: {
|
||||
timeoutMs?: number
|
||||
intervalMs?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount root the container exposes. `kind` is informational —
|
||||
* `'shared'` mounts are bind-mounted once and used by every agent;
|
||||
* `'per-agent'` mounts are bind-mounted per agent and the harness's
|
||||
* agent-delete flow is expected to clean them up. Both are
|
||||
* containment-checked by `toContainerPath` / `toHostPath`.
|
||||
*/
|
||||
export interface MountRoot {
|
||||
hostPath: string
|
||||
containerPath: string
|
||||
kind: 'shared' | 'per-agent'
|
||||
}
|
||||
|
||||
export interface ContainerStatusSnapshot {
|
||||
adapterId: string
|
||||
containerName: string
|
||||
state: ContainerState
|
||||
lastError: string | null
|
||||
lastErrorAt: number | null
|
||||
}
|
||||
|
||||
/** What the caller wants to run inside the container. */
|
||||
export interface ExecSpec {
|
||||
/** Argv inside the container — first element is the executable. */
|
||||
argv: readonly [string, ...string[]]
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ExecResult {
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
/** Reset granularity — see the architecture doc / team brief. */
|
||||
export type ResetLevel = 'soft' | 'wipe-agent' | 'hard'
|
||||
|
||||
export interface ResetOptions {
|
||||
/** Required for `level === 'wipe-agent'` — agent id whose home dir
|
||||
* should be removed. Ignored for soft/hard. */
|
||||
agentId?: string
|
||||
onLog?: (msg: string) => void
|
||||
}
|
||||
@@ -35,6 +35,14 @@ export interface ContainerSpec {
|
||||
mounts?: MountSpec[]
|
||||
addHosts?: string[]
|
||||
health?: HealthConfig
|
||||
/**
|
||||
* Override the image's ENTRYPOINT. When set, nerdctl is invoked with
|
||||
* `--entrypoint <value>`; the `command` array is appended as args to
|
||||
* this entrypoint. Useful for keeping a service-style image alive in
|
||||
* the background (e.g. `tini -- sh -c "exec sleep infinity"`) so that
|
||||
* other code paths can `nerdctl exec` into it per turn.
|
||||
*/
|
||||
entrypoint?: string
|
||||
command?: string[]
|
||||
}
|
||||
|
||||
@@ -51,6 +59,11 @@ export interface WaitForContainerNameReleaseOptions {
|
||||
intervalMs?: number
|
||||
}
|
||||
|
||||
export interface WaitForContainerRunningOptions {
|
||||
timeoutMs?: number
|
||||
intervalMs?: number
|
||||
}
|
||||
|
||||
export interface LogLine {
|
||||
stream: 'stdout' | 'stderr'
|
||||
line: string
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE `produced_files` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`agent_definition_id` text NOT NULL,
|
||||
`session_key` text NOT NULL,
|
||||
`turn_id` text NOT NULL,
|
||||
`turn_prompt` text NOT NULL,
|
||||
`path` text NOT NULL,
|
||||
`size` integer NOT NULL,
|
||||
`mtime_ms` integer NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`detected_by` text DEFAULT 'diff' NOT NULL,
|
||||
FOREIGN KEY (`agent_definition_id`) REFERENCES `agent_definitions`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `produced_files_agent_path_unique` ON `produced_files` (`agent_definition_id`,`path`);--> statement-breakpoint
|
||||
CREATE INDEX `produced_files_agent_created_idx` ON `produced_files` (`agent_definition_id`,`created_at`);--> statement-breakpoint
|
||||
CREATE INDEX `produced_files_turn_idx` ON `produced_files` (`turn_id`);--> statement-breakpoint
|
||||
CREATE INDEX `produced_files_session_idx` ON `produced_files` (`session_key`);
|
||||
@@ -0,0 +1,338 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "a8560df5-6cbe-46c2-b7df-ef0d09d232bf",
|
||||
"prevId": "6be24444-91aa-492e-96e5-d84c0f020468",
|
||||
"tables": {
|
||||
"agent_definitions": {
|
||||
"name": "agent_definitions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"adapter": {
|
||||
"name": "adapter",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model_id": {
|
||||
"name": "model_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"name": "reasoning_effort",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"permission_mode": {
|
||||
"name": "permission_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'approve-all'"
|
||||
},
|
||||
"session_key": {
|
||||
"name": "session_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pinned": {
|
||||
"name": "pinned",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"adapter_config_json": {
|
||||
"name": "adapter_config_json",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"agent_definitions_session_key_unique": {
|
||||
"name": "agent_definitions_session_key_unique",
|
||||
"columns": [
|
||||
"session_key"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"agent_definitions_updated_at_idx": {
|
||||
"name": "agent_definitions_updated_at_idx",
|
||||
"columns": [
|
||||
"updated_at"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"agent_definitions_adapter_updated_at_idx": {
|
||||
"name": "agent_definitions_adapter_updated_at_idx",
|
||||
"columns": [
|
||||
"adapter",
|
||||
"updated_at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"oauth_tokens": {
|
||||
"name": "oauth_tokens",
|
||||
"columns": {
|
||||
"browseros_id": {
|
||||
"name": "browseros_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"oauth_tokens_browseros_id_idx": {
|
||||
"name": "oauth_tokens_browseros_id_idx",
|
||||
"columns": [
|
||||
"browseros_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"oauth_tokens_browseros_id_provider_pk": {
|
||||
"columns": [
|
||||
"browseros_id",
|
||||
"provider"
|
||||
],
|
||||
"name": "oauth_tokens_browseros_id_provider_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"produced_files": {
|
||||
"name": "produced_files",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_definition_id": {
|
||||
"name": "agent_definition_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_key": {
|
||||
"name": "session_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"turn_id": {
|
||||
"name": "turn_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"turn_prompt": {
|
||||
"name": "turn_prompt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"path": {
|
||||
"name": "path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mtime_ms": {
|
||||
"name": "mtime_ms",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"detected_by": {
|
||||
"name": "detected_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'diff'"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"produced_files_agent_path_unique": {
|
||||
"name": "produced_files_agent_path_unique",
|
||||
"columns": [
|
||||
"agent_definition_id",
|
||||
"path"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"produced_files_agent_created_idx": {
|
||||
"name": "produced_files_agent_created_idx",
|
||||
"columns": [
|
||||
"agent_definition_id",
|
||||
"created_at"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"produced_files_turn_idx": {
|
||||
"name": "produced_files_turn_idx",
|
||||
"columns": [
|
||||
"turn_id"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"produced_files_session_idx": {
|
||||
"name": "produced_files_session_idx",
|
||||
"columns": [
|
||||
"session_key"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"produced_files_agent_definition_id_agent_definitions_id_fk": {
|
||||
"name": "produced_files_agent_definition_id_agent_definitions_id_fk",
|
||||
"tableFrom": "produced_files",
|
||||
"tableTo": "agent_definitions",
|
||||
"columnsFrom": [
|
||||
"agent_definition_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@
|
||||
"when": 1777752799806,
|
||||
"tag": "0001_lazy_orphan",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1777902205667,
|
||||
"tag": "0002_chemical_whirlwind",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export const agentDefinitions = sqliteTable(
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
adapter: text('adapter', {
|
||||
enum: ['claude', 'codex', 'openclaw'],
|
||||
enum: ['claude', 'codex', 'openclaw', 'hermes'],
|
||||
}).notNull(),
|
||||
modelId: text('model_id').notNull(),
|
||||
reasoningEffort: text('reasoning_effort').notNull(),
|
||||
|
||||
@@ -6,3 +6,4 @@
|
||||
|
||||
export * from './agents'
|
||||
export * from './oauth'
|
||||
export * from './produced-files'
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
sqliteTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
} from 'drizzle-orm/sqlite-core'
|
||||
import { agentDefinitions } from './agents'
|
||||
|
||||
/**
|
||||
* Files an OpenClaw agent produced as part of a chat turn. Populated by
|
||||
* a per-turn workspace diff: snapshot the agent's CWD before
|
||||
* `chatStream(...)` runs, re-scan after the SSE `done` event fires,
|
||||
* write rows for any new or modified path. The rail UI groups by
|
||||
* `turn_id` and the inline artifact card renders one row per file.
|
||||
*
|
||||
* Schema is intentionally adapter-agnostic — V1 only enables the
|
||||
* watcher for the openclaw adapter, but V2 can plug Claude / Codex
|
||||
* into the same table without migrating.
|
||||
*/
|
||||
export const producedFiles = sqliteTable(
|
||||
'produced_files',
|
||||
{
|
||||
/** Stable id; opaque file handle in download/preview URLs. */
|
||||
id: text('id').primaryKey(),
|
||||
|
||||
/** FK → agent_definitions.id; CASCADE so agent deletion sweeps. */
|
||||
agentDefinitionId: text('agent_definition_id')
|
||||
.notNull()
|
||||
.references(() => agentDefinitions.id, { onDelete: 'cascade' }),
|
||||
|
||||
/** OpenClaw session that owns this turn (e.g. `session-abc`). */
|
||||
sessionKey: text('session_key').notNull(),
|
||||
|
||||
/** Identifier for the assistant turn that produced the file. */
|
||||
turnId: text('turn_id').notNull(),
|
||||
|
||||
/**
|
||||
* The user prompt that initiated this turn — denormalised so the
|
||||
* rail's "group by prompt" header doesn't have to join the JSONL
|
||||
* log. Capped at 280 chars in code; the column is unbounded.
|
||||
*/
|
||||
turnPrompt: text('turn_prompt').notNull(),
|
||||
|
||||
/** Workspace-relative path (e.g. `reports/q1.pdf`). */
|
||||
path: text('path').notNull(),
|
||||
|
||||
size: integer('size').notNull(),
|
||||
|
||||
/** mtime in ms — used to detect re-modifications. */
|
||||
mtimeMs: integer('mtime_ms').notNull(),
|
||||
|
||||
/** Server clock when our watcher first saw it. */
|
||||
createdAt: integer('created_at').notNull(),
|
||||
|
||||
/**
|
||||
* `'diff'` for the V1 per-turn workspace diff watcher;
|
||||
* `'tool'` reserved for the future tool-event parsing layer.
|
||||
*/
|
||||
detectedBy: text('detected_by', { enum: ['diff', 'tool'] })
|
||||
.notNull()
|
||||
.default('diff'),
|
||||
},
|
||||
(table) => [
|
||||
// One row per (agent, path) pair — re-modifications update in place,
|
||||
// so a tool that overwrites `report.pdf` doesn't accumulate
|
||||
// duplicate rows. The most recent turn that touched the file owns
|
||||
// the row.
|
||||
uniqueIndex('produced_files_agent_path_unique').on(
|
||||
table.agentDefinitionId,
|
||||
table.path,
|
||||
),
|
||||
// Outputs-rail query: latest files per agent.
|
||||
index('produced_files_agent_created_idx').on(
|
||||
table.agentDefinitionId,
|
||||
table.createdAt,
|
||||
),
|
||||
// Inline-card query: by turn.
|
||||
index('produced_files_turn_idx').on(table.turnId),
|
||||
// Cleanup hook: by session (when a session is deleted later).
|
||||
index('produced_files_session_idx').on(table.sessionKey),
|
||||
],
|
||||
)
|
||||
|
||||
export type ProducedFileRow = InferSelectModel<typeof producedFiles>
|
||||
export type NewProducedFileRow = InferInsertModel<typeof producedFiles>
|
||||
@@ -63,6 +63,25 @@ export class ContainerNameReleaseTimeoutError extends VmError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Container's process never reached `running` state within the
|
||||
* timeout window. Distinct from `ContainerNameReleaseTimeoutError`
|
||||
* (which is about deletion). Thrown by
|
||||
* `ContainerCli.waitForContainerRunning` and surfaced by the
|
||||
* managed-container layer when a `start()` finishes the create+start
|
||||
* commands but the container hasn't actually come up.
|
||||
*/
|
||||
export class ContainerNotRunningError extends VmError {
|
||||
constructor(
|
||||
public readonly containerName: string,
|
||||
public readonly timeoutMs: number,
|
||||
) {
|
||||
super(
|
||||
`Timed out waiting ${timeoutMs}ms for container "${containerName}" to reach running state`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageLoadError extends VmError {
|
||||
constructor(
|
||||
public readonly imageRef: string,
|
||||
|
||||
@@ -21,6 +21,13 @@ import { CdpBackend } from './browser/backends/cdp'
|
||||
import { Browser } from './browser/browser'
|
||||
import type { ServerConfig } from './config'
|
||||
import { INLINED_ENV } from './env'
|
||||
import {
|
||||
configureClaudeRuntime,
|
||||
configureCodexRuntime,
|
||||
configureHermesRuntime,
|
||||
configureOpenClawRuntime,
|
||||
getHermesRuntime,
|
||||
} from './lib/agents/runtime'
|
||||
import {
|
||||
cleanOldSessions,
|
||||
ensureBrowserosDir,
|
||||
@@ -60,6 +67,9 @@ export class Application {
|
||||
|
||||
const resourcesDir = path.resolve(this.config.resourcesDir)
|
||||
configureVmRuntime({ resourcesDir })
|
||||
configureClaudeRuntime()
|
||||
configureCodexRuntime()
|
||||
configureOpenClawRuntime({ resourcesDir })
|
||||
await this.initCoreServices()
|
||||
|
||||
if (!this.config.cdpPort) {
|
||||
@@ -150,6 +160,33 @@ 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),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
metrics.log('http_server.started', { version: VERSION })
|
||||
}
|
||||
|
||||
@@ -159,6 +196,9 @@ export class Application {
|
||||
getOpenClawService()
|
||||
.shutdown()
|
||||
.catch(() => {})
|
||||
getHermesRuntime()
|
||||
?.executeAction({ type: 'stop' })
|
||||
.catch(() => {})
|
||||
removeServerConfigSync()
|
||||
|
||||
// Immediate exit without graceful shutdown. Chromium may kill us on update/restart,
|
||||
|
||||
@@ -696,7 +696,7 @@ function createFakeService(agents: AgentDefinition[]) {
|
||||
},
|
||||
async createAgent(input: {
|
||||
name: string
|
||||
adapter: 'claude' | 'codex' | 'openclaw'
|
||||
adapter: 'claude' | 'codex' | 'openclaw' | 'hermes'
|
||||
modelId?: string
|
||||
reasoningEffort?: string
|
||||
}) {
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AgentHarnessService } from '../../../../src/api/services/agents/agent-harness-service'
|
||||
import type { AgentStore } from '../../../../src/lib/agents/agent-store'
|
||||
import type { AgentDefinition } from '../../../../src/lib/agents/agent-types'
|
||||
@@ -440,6 +443,206 @@ describe('AgentHarnessService', () => {
|
||||
const listed = await service.listAgentsWithActivity()
|
||||
expect(listed[0]?.status).toBe('error')
|
||||
})
|
||||
|
||||
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({
|
||||
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({
|
||||
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 expect(
|
||||
service.createAgent({
|
||||
name: 'Unknown Hermes',
|
||||
adapter: 'hermes',
|
||||
providerType: 'bedrock',
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'm',
|
||||
}),
|
||||
).rejects.toThrow(/not supported/i)
|
||||
expect(agents).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
function stubRuntime(): AgentRuntime {
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
buildContainerRuntime,
|
||||
migrateLegacyOpenClawDir,
|
||||
} from '../../../../src/api/services/openclaw/container-runtime-factory'
|
||||
import { logger } from '../../../../src/lib/logger'
|
||||
|
||||
describe('container-runtime factory', () => {
|
||||
let root: string
|
||||
let resourcesDir: string
|
||||
let originalNodeEnv: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp('/tmp/openclaw-runtime-factory-')
|
||||
resourcesDir = join(root, 'resources')
|
||||
const limaRoot = join(resourcesDir, 'bin', 'third_party', 'lima')
|
||||
const limactlPath = join(limaRoot, 'bin', 'limactl')
|
||||
const armGuestAgentPath = join(
|
||||
limaRoot,
|
||||
'share',
|
||||
'lima',
|
||||
'lima-guestagent.Linux-aarch64.gz',
|
||||
)
|
||||
const x64GuestAgentPath = join(
|
||||
limaRoot,
|
||||
'share',
|
||||
'lima',
|
||||
'lima-guestagent.Linux-x86_64.gz',
|
||||
)
|
||||
await mkdir(dirname(limactlPath), { recursive: true })
|
||||
await mkdir(dirname(armGuestAgentPath), { recursive: true })
|
||||
await mkdir(join(resourcesDir, 'vm'), { recursive: true })
|
||||
await writeFile(limactlPath, '#!/bin/sh\n')
|
||||
await writeFile(armGuestAgentPath, 'guest-agent\n')
|
||||
await writeFile(x64GuestAgentPath, 'guest-agent\n')
|
||||
await writeFile(
|
||||
join(resourcesDir, 'vm', 'browseros-vm.yaml'),
|
||||
'mounts: []\n',
|
||||
)
|
||||
originalNodeEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'production'
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV
|
||||
} else {
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
}
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rejects non-macOS platforms', () => {
|
||||
expect(() =>
|
||||
buildContainerRuntime({
|
||||
resourcesDir,
|
||||
projectDir: join(root, 'project'),
|
||||
browserosRoot: root,
|
||||
platform: 'linux',
|
||||
}),
|
||||
).toThrow('supports macOS only')
|
||||
})
|
||||
|
||||
it('returns a disabled runtime on non-macOS platforms in test mode', async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
|
||||
const runtime = buildContainerRuntime({
|
||||
resourcesDir,
|
||||
projectDir: join(root, 'project'),
|
||||
browserosRoot: root,
|
||||
platform: 'linux',
|
||||
})
|
||||
|
||||
await expect(runtime.getMachineStatus()).resolves.toEqual({
|
||||
initialized: false,
|
||||
running: false,
|
||||
})
|
||||
await expect(runtime.ensureReady()).rejects.toThrow('supports macOS only')
|
||||
await expect(runtime.prewarmGatewayImage()).rejects.toThrow(
|
||||
'supports macOS only',
|
||||
)
|
||||
await expect(runtime.isGatewayCurrent()).resolves.toBe(false)
|
||||
await expect(runtime.stopVm()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('migrates legacy OpenClaw state into the VM state directory', async () => {
|
||||
const legacyFile = join(root, 'openclaw', '.openclaw', 'openclaw.json')
|
||||
await mkdir(dirname(legacyFile), { recursive: true })
|
||||
await writeFile(legacyFile, '{"ok":true}\n')
|
||||
|
||||
await migrateLegacyOpenClawDir(root)
|
||||
|
||||
await expect(
|
||||
readFile(
|
||||
join(root, 'vm', 'openclaw', '.openclaw', 'openclaw.json'),
|
||||
'utf8',
|
||||
),
|
||||
).resolves.toBe('{"ok":true}\n')
|
||||
await expect(readFile(legacyFile, 'utf8')).resolves.toBe('{"ok":true}\n')
|
||||
})
|
||||
|
||||
it('builds a runtime whose image loader pulls directly through nerdctl', async () => {
|
||||
const runtime = buildContainerRuntime({
|
||||
resourcesDir,
|
||||
projectDir: join(root, 'project'),
|
||||
browserosRoot: root,
|
||||
platform: 'darwin',
|
||||
})
|
||||
|
||||
expect(runtime).toBeDefined()
|
||||
})
|
||||
|
||||
it('leaves both directories in place when new OpenClaw state already exists', async () => {
|
||||
const legacyFile = join(root, 'openclaw', 'legacy.txt')
|
||||
const newFile = join(root, 'vm', 'openclaw', 'new.txt')
|
||||
await mkdir(dirname(legacyFile), { recursive: true })
|
||||
await mkdir(dirname(newFile), { recursive: true })
|
||||
await writeFile(legacyFile, 'legacy')
|
||||
await writeFile(newFile, 'new')
|
||||
const originalWarn = logger.warn
|
||||
const warnings: string[] = []
|
||||
logger.warn = (message) => warnings.push(message)
|
||||
|
||||
try {
|
||||
await migrateLegacyOpenClawDir(root)
|
||||
} finally {
|
||||
logger.warn = originalWarn
|
||||
}
|
||||
|
||||
await expect(readFile(legacyFile, 'utf8')).resolves.toBe('legacy')
|
||||
await expect(readFile(newFile, 'utf8')).resolves.toBe('new')
|
||||
expect(warnings).toContain(
|
||||
'OpenClaw legacy and VM state directories both exist',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,376 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it, mock } from 'bun:test'
|
||||
import {
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
OPENCLAW_IMAGE,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { ContainerRuntime } from '../../../../src/api/services/openclaw/container-runtime'
|
||||
import { ContainerNameInUseError } from '../../../../src/lib/vm/errors'
|
||||
|
||||
const PROJECT_DIR = '/tmp/openclaw'
|
||||
const OPENCLAW_NAME_RELEASE_WAIT = { timeoutMs: 10_000, intervalMs: 100 }
|
||||
const defaultSpec = {
|
||||
hostPort: 18789,
|
||||
hostHome: '/Users/me/.browseros/vm/openclaw',
|
||||
envFilePath: '/Users/me/.browseros/vm/openclaw/.openclaw/.env',
|
||||
gatewayToken: 'token-123',
|
||||
timezone: 'America/Los_Angeles',
|
||||
}
|
||||
|
||||
describe('ContainerRuntime', () => {
|
||||
it('starts the gateway by loading the image, creating, and starting a container', async () => {
|
||||
const deps = createDeps()
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await runtime.startGateway(defaultSpec)
|
||||
|
||||
expect(deps.shell.removeContainer).toHaveBeenCalledWith(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
{ force: true },
|
||||
undefined,
|
||||
)
|
||||
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledWith(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
OPENCLAW_NAME_RELEASE_WAIT,
|
||||
)
|
||||
expect(deps.loader.ensureAgentImageLoaded).toHaveBeenCalledWith(
|
||||
'openclaw',
|
||||
undefined,
|
||||
)
|
||||
expect(deps.shell.createContainer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
image: OPENCLAW_IMAGE,
|
||||
restart: 'unless-stopped',
|
||||
ports: [
|
||||
{
|
||||
hostIp: '127.0.0.1',
|
||||
hostPort: 18789,
|
||||
containerPort: 18789,
|
||||
},
|
||||
],
|
||||
envFile: '/mnt/browseros/vm/openclaw/.openclaw/.env',
|
||||
mounts: [
|
||||
{
|
||||
source: '/mnt/browseros/vm/openclaw',
|
||||
target: '/home/node',
|
||||
},
|
||||
],
|
||||
addHosts: ['host.containers.internal:192.168.5.2'],
|
||||
}),
|
||||
undefined,
|
||||
)
|
||||
expect(deps.shell.startContainer).toHaveBeenCalledWith(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
)
|
||||
})
|
||||
|
||||
it('reconciles and retries when gateway create reports name-in-use', async () => {
|
||||
const deps = createDeps()
|
||||
deps.shell.createContainer = mock(async () => {
|
||||
if (deps.shell.createContainer.mock.calls.length === 1) {
|
||||
throw new ContainerNameInUseError(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
'nerdctl create',
|
||||
1,
|
||||
`name-store error\nname "${OPENCLAW_GATEWAY_CONTAINER_NAME}" is already used`,
|
||||
)
|
||||
}
|
||||
})
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await runtime.startGateway(defaultSpec)
|
||||
|
||||
expect(deps.shell.createContainer).toHaveBeenCalledTimes(2)
|
||||
expect(deps.shell.removeContainer).toHaveBeenCalledTimes(2)
|
||||
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledTimes(2)
|
||||
expect(deps.shell.startContainer).toHaveBeenCalledWith(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
)
|
||||
})
|
||||
|
||||
it('bounds gateway create retries when the name stays in use', async () => {
|
||||
const deps = createDeps()
|
||||
deps.shell.createContainer = mock(async () => {
|
||||
throw new ContainerNameInUseError(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
'nerdctl create',
|
||||
1,
|
||||
`name-store error\nname "${OPENCLAW_GATEWAY_CONTAINER_NAME}" is already used`,
|
||||
)
|
||||
})
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await expect(runtime.startGateway(defaultSpec)).rejects.toBeInstanceOf(
|
||||
ContainerNameInUseError,
|
||||
)
|
||||
|
||||
expect(deps.shell.createContainer).toHaveBeenCalledTimes(3)
|
||||
expect(deps.shell.removeContainer).toHaveBeenCalledTimes(3)
|
||||
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledTimes(3)
|
||||
expect(deps.shell.startContainer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses OPENCLAW_IMAGE as a direct image override', async () => {
|
||||
const previous = process.env.OPENCLAW_IMAGE
|
||||
process.env.OPENCLAW_IMAGE = 'localhost/openclaw:test'
|
||||
const deps = createDeps()
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
try {
|
||||
await runtime.startGateway(defaultSpec)
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.OPENCLAW_IMAGE
|
||||
else process.env.OPENCLAW_IMAGE = previous
|
||||
}
|
||||
|
||||
expect(deps.loader.ensureImageLoaded).toHaveBeenCalledWith(
|
||||
'localhost/openclaw:test',
|
||||
undefined,
|
||||
)
|
||||
expect(deps.loader.ensureAgentImageLoaded).not.toHaveBeenCalled()
|
||||
expect(deps.shell.createContainer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ image: 'localhost/openclaw:test' }),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it('delegates ensureReady and stopVm to VmRuntime', async () => {
|
||||
const deps = createDeps()
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await runtime.ensureReady()
|
||||
await runtime.stopVm()
|
||||
|
||||
expect(deps.vm.ensureReady).toHaveBeenCalled()
|
||||
expect(deps.vm.getDefaultGateway).toHaveBeenCalled()
|
||||
expect(deps.vm.stopVm).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs setup commands with guest paths', async () => {
|
||||
const deps = createDeps()
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await runtime.runGatewaySetupCommand(
|
||||
['node', 'dist/index.js', 'agents', 'list', '--json'],
|
||||
defaultSpec,
|
||||
)
|
||||
|
||||
expect(deps.shell.runCommand).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
'create',
|
||||
'--name',
|
||||
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
|
||||
'--env-file',
|
||||
'/mnt/browseros/vm/openclaw/.openclaw/.env',
|
||||
'-v',
|
||||
'/mnt/browseros/vm/openclaw:/home/node',
|
||||
'--add-host',
|
||||
'host.containers.internal:192.168.5.2',
|
||||
OPENCLAW_IMAGE,
|
||||
]),
|
||||
undefined,
|
||||
)
|
||||
expect(deps.shell.runCommand).toHaveBeenCalledWith(
|
||||
['start', '-a', `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`],
|
||||
undefined,
|
||||
)
|
||||
expect(deps.shell.removeContainer).toHaveBeenCalledWith(
|
||||
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
|
||||
{ force: true },
|
||||
undefined,
|
||||
)
|
||||
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledWith(
|
||||
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
|
||||
OPENCLAW_NAME_RELEASE_WAIT,
|
||||
)
|
||||
})
|
||||
|
||||
it('reconciles and retries when setup create reports name-in-use', async () => {
|
||||
const deps = createDeps()
|
||||
let setupCreateCount = 0
|
||||
deps.shell.runCommand = mock(async (args: string[]) => {
|
||||
if (args[0] === 'create') {
|
||||
setupCreateCount += 1
|
||||
if (setupCreateCount === 1) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
stdout: '',
|
||||
stderr: `name-store error\nname "${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup" is already used`,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { exitCode: 0, stdout: '', stderr: '' }
|
||||
})
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.runGatewaySetupCommand(
|
||||
['node', 'dist/index.js', 'agents', 'list', '--json'],
|
||||
defaultSpec,
|
||||
),
|
||||
).resolves.toBe(0)
|
||||
|
||||
expect(setupCreateCount).toBe(2)
|
||||
expect(deps.shell.waitForContainerNameRelease).toHaveBeenCalledTimes(2)
|
||||
expect(deps.shell.removeContainer).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('tails and fetches gateway logs through the new transport', async () => {
|
||||
const deps = createDeps()
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
const stop = runtime.tailGatewayLogs(() => {})
|
||||
const logs = await runtime.getGatewayLogs(10)
|
||||
stop()
|
||||
|
||||
expect(deps.shell.tailLogs).toHaveBeenCalledWith(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(deps.shell.runCommand).toHaveBeenCalledWith(
|
||||
['logs', '-n', '10', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(logs).toEqual(['log line'])
|
||||
})
|
||||
|
||||
it('prewarms the gateway image without creating a container', async () => {
|
||||
const deps = createDeps()
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await runtime.prewarmGatewayImage()
|
||||
|
||||
expect(deps.loader.ensureAgentImageLoaded).toHaveBeenCalledWith(
|
||||
'openclaw',
|
||||
undefined,
|
||||
)
|
||||
expect(deps.shell.createContainer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detects when the gateway container uses the current image', async () => {
|
||||
const deps = createDeps()
|
||||
deps.shell.containerImageRef.mockImplementation(async () => OPENCLAW_IMAGE)
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await expect(runtime.isGatewayCurrent()).resolves.toBe(true)
|
||||
expect(deps.shell.containerImageRef).toHaveBeenCalledWith(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
)
|
||||
})
|
||||
|
||||
it('treats a digest-qualified current image ref as current', async () => {
|
||||
const deps = createDeps()
|
||||
deps.shell.containerImageRef.mockImplementation(
|
||||
async () => `${OPENCLAW_IMAGE}@sha256:${'a'.repeat(64)}`,
|
||||
)
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await expect(runtime.isGatewayCurrent()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('detects when the gateway container uses an old image', async () => {
|
||||
const deps = createDeps()
|
||||
deps.shell.containerImageRef.mockImplementation(
|
||||
async () => 'ghcr.io/openclaw/openclaw:old',
|
||||
)
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await expect(runtime.isGatewayCurrent()).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
function createDeps() {
|
||||
return {
|
||||
vm: {
|
||||
ensureReady: mock(async () => {}),
|
||||
getDefaultGateway: mock(async () => '192.168.5.2'),
|
||||
stopVm: mock(async () => {}),
|
||||
isReady: mock(async () => true),
|
||||
},
|
||||
shell: {
|
||||
createContainer: mock(async () => {}),
|
||||
startContainer: mock(async () => {}),
|
||||
stopContainer: mock(async () => {}),
|
||||
removeContainer: mock(async () => {}),
|
||||
containerImageRef: mock(async () => OPENCLAW_IMAGE),
|
||||
waitForContainerNameRelease: mock(async () => {}),
|
||||
exec: mock(async () => 0),
|
||||
runCommand: mock(
|
||||
async (_args: string[], onLog?: (line: string) => void) => {
|
||||
onLog?.('log line')
|
||||
return { exitCode: 0, stdout: 'log line\n', stderr: '' }
|
||||
},
|
||||
),
|
||||
tailLogs: mock(() => () => {}),
|
||||
},
|
||||
loader: {
|
||||
ensureImageLoaded: mock(async () => {}),
|
||||
ensureAgentImageLoaded: mock(async () => OPENCLAW_IMAGE),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
cleanHistoryUserText,
|
||||
convertOpenClawHistoryToAgentHistory,
|
||||
} from '../../../../src/api/services/openclaw/history-mapper'
|
||||
import type { OpenClawSessionHistory } from '../../../../src/api/services/openclaw/openclaw-http-client'
|
||||
|
||||
describe('cleanHistoryUserText', () => {
|
||||
it('extracts the cron payload and drops the trailer', () => {
|
||||
const raw =
|
||||
'[cron:681df8ba-85e0-404e-a6ea-891d0f5068af hello-8] Print hello\n' +
|
||||
'Current time: Tuesday, May 5th, 2026 - 2:26 AM (Asia/Calcutta) / 2026-05-04 20:56 UTC\n\n' +
|
||||
'Use the message tool if you need to notify the user directly with an explicit target. ' +
|
||||
'If you do not send directly, your final plain-text reply will be delivered automatically.'
|
||||
expect(cleanHistoryUserText(raw)).toBe('Print hello')
|
||||
})
|
||||
|
||||
it('extracts a multiline cron payload and drops the trailer', () => {
|
||||
const raw =
|
||||
'[cron:abcd1234-0000-0000-0000-000000000000 weather] Tell me the weather in Tokyo\n' +
|
||||
'and report back briefly.\n' +
|
||||
'Current time: Tuesday, May 5th, 2026 - 2:26 AM (Asia/Calcutta) / 2026-05-04 20:56 UTC\n\n' +
|
||||
'Use the message tool if you need to notify the user directly with an explicit target.'
|
||||
expect(cleanHistoryUserText(raw)).toBe(
|
||||
'Tell me the weather in Tokyo\nand report back briefly.',
|
||||
)
|
||||
})
|
||||
|
||||
// Mirrors `BROWSEROS_ACP_AGENT_INSTRUCTIONS` from acpx-runtime.ts.
|
||||
// `unwrapBrowserosAcpUserMessage`'s `stripOuterRoleEnvelope` performs
|
||||
// an exact-prefix/suffix match against this constant, so test fixtures
|
||||
// need the full text — not a truncated stand-in.
|
||||
const ROLE_BLOCK =
|
||||
'<role>\n' +
|
||||
'You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.\n\n' +
|
||||
'Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.\n' +
|
||||
'</role>'
|
||||
|
||||
it('unwraps the BrowserOS ACP user_request envelope', () => {
|
||||
const raw = `${ROLE_BLOCK}\n\n<user_request>\nhey\n</user_request>`
|
||||
expect(cleanHistoryUserText(raw)).toBe('hey')
|
||||
})
|
||||
|
||||
it("strips OpenClaw acp-cli's leading [Working directory:] line", () => {
|
||||
// OpenClaw 2026.5.x's acp-cli prepends `[Working directory: <path>]
|
||||
// \n\n` before the BrowserOS envelope. We strip that line up-front
|
||||
// so the inner `<role>…</role>\n\n<user_request>` envelope can be
|
||||
// unwrapped by `unwrapBrowserosAcpUserMessage`.
|
||||
const raw =
|
||||
'[Working directory: /Users/me/.browseros-dev/agents/harness/workspace]\n\n' +
|
||||
`${ROLE_BLOCK}\n\n<user_request>\nhey\n</user_request>`
|
||||
expect(cleanHistoryUserText(raw)).toBe('hey')
|
||||
})
|
||||
|
||||
it('strips the full OpenClaw acp-cli envelope on image-attachment turns', () => {
|
||||
// OpenClaw 2026.5.4+ (post image-bypass deletion) wraps user
|
||||
// messages with stacked envelope lines:
|
||||
// [media attached: <path> (<mime>)]
|
||||
// [<weekday> <date> <tz>] [Working directory: <path>]
|
||||
// <BrowserOS role envelope>
|
||||
const raw =
|
||||
'[media attached: /home/node/.openclaw/media/inbound/image---abc.png (image/png)]\n' +
|
||||
'[Thu 2026-05-07 02:07 GMT+5:30] [Working directory: /Users/me/.browseros-dev/agents/harness/workspace]\n\n' +
|
||||
`${ROLE_BLOCK}\n\n<user_request>\nWhat color is this?\n</user_request>`
|
||||
expect(cleanHistoryUserText(raw)).toBe('What color is this?')
|
||||
})
|
||||
|
||||
it('strips multiple stacked [media attached:] lines (one per attachment)', () => {
|
||||
const raw =
|
||||
'[media attached: /home/node/.openclaw/media/inbound/image---a.png (image/png)]\n' +
|
||||
'[media attached: /home/node/.openclaw/media/inbound/image---b.jpg (image/jpeg)]\n' +
|
||||
'[Thu 2026-05-07 02:07 GMT+5:30] [Working directory: /workspace]\n\n' +
|
||||
`${ROLE_BLOCK}\n\n<user_request>\nCompare these.\n</user_request>`
|
||||
expect(cleanHistoryUserText(raw)).toBe('Compare these.')
|
||||
})
|
||||
|
||||
it('splits queued-marker concatenations and cleans each chunk', () => {
|
||||
// When multiple prompts queue up while a turn is active, BrowserOS
|
||||
// joins them with the queued-marker line. Each chunk between markers
|
||||
// is its own message that should be cleaned independently.
|
||||
const raw =
|
||||
'[Queued user message that arrived while the previous turn was still active]\n' +
|
||||
"[cron:aaaa hello-job-1] print('hello')\n" +
|
||||
'Current time: 2026-05-05 16:00 UTC\n\n' +
|
||||
'Use the message tool if you need to notify the user directly with an explicit target.\n' +
|
||||
'[Queued user message that arrived while the previous turn was still active]\n' +
|
||||
"[cron:bbbb hello-job-2] print('world')\n" +
|
||||
'Current time: 2026-05-05 16:01 UTC\n\n' +
|
||||
'Use the message tool if you need to notify the user directly with an explicit target.'
|
||||
expect(cleanHistoryUserText(raw)).toBe("print('hello')\nprint('world')")
|
||||
})
|
||||
|
||||
it('drops a Subagent Context message entirely', () => {
|
||||
// OpenClaw seeds a nested subagent's session with a "Subagent
|
||||
// Context" prefix that's pure scaffolding. The actual task lives in
|
||||
// the system prompt, so the user message body is meaningless to
|
||||
// surface. cleanHistoryUserText returns empty; the converter then
|
||||
// skips the entry so it doesn't render an empty bubble.
|
||||
const raw =
|
||||
'[Subagent Context] You are running as a subagent (depth 1/1). ' +
|
||||
'Results auto-announce to your requester; do not busy-poll for status.\n\n' +
|
||||
'Begin. Your assigned task is in the system prompt under **Your Role**.'
|
||||
expect(cleanHistoryUserText(raw)).toBe('')
|
||||
})
|
||||
|
||||
it('drops empty chunks left by leading queued marker', () => {
|
||||
// The blob often opens with a marker (no content before it). Empty
|
||||
// chunks should be dropped so we don't emit a leading newline.
|
||||
const raw =
|
||||
'[Queued user message that arrived while the previous turn was still active]\n' +
|
||||
'[cron:aaaa job] payload-only\n' +
|
||||
'Current time: now'
|
||||
expect(cleanHistoryUserText(raw)).toBe('payload-only')
|
||||
})
|
||||
|
||||
it('preserves messages that match no known scaffolding', () => {
|
||||
expect(cleanHistoryUserText('hello there')).toBe('hello there')
|
||||
expect(cleanHistoryUserText('multi\nline\nuser text')).toBe(
|
||||
'multi\nline\nuser text',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(cleanHistoryUserText('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertOpenClawHistoryToAgentHistory', () => {
|
||||
it('strips cron scaffolding from user messages while preserving assistant text', () => {
|
||||
const raw: OpenClawSessionHistory = {
|
||||
sessionKey: 'agent:demo:main',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '' as never,
|
||||
// The HTTP endpoint actually returns content as an array of typed
|
||||
// blocks at runtime; the type is `string` for backward-compat.
|
||||
// Cast via `unknown` to reflect runtime.
|
||||
...({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
'[cron:abc-123 hello-1] Print hello\n' +
|
||||
'Current time: 2026-05-05 16:00 UTC\n\n' +
|
||||
'Use the message tool if you need to notify the user directly with an explicit target.',
|
||||
},
|
||||
],
|
||||
} as unknown as { content: never }),
|
||||
timestamp: 1000,
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '' as never,
|
||||
...({
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
} as unknown as { content: never }),
|
||||
timestamp: 1001,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const out = convertOpenClawHistoryToAgentHistory('demo', raw)
|
||||
expect(out.items.map((i) => ({ role: i.role, text: i.text }))).toEqual([
|
||||
{ role: 'user', text: 'Print hello' },
|
||||
{ role: 'assistant', text: 'hello' },
|
||||
])
|
||||
})
|
||||
|
||||
it('drops assistant turns that have only reasoning (no text, no tools)', () => {
|
||||
// MiniMax with thinking:minimal often returns only `thinking` blocks
|
||||
// for trivial prompts ("Print hello"). The empty text bubble with a
|
||||
// dangling reasoning collapsible reads as broken UI; cleaner to skip.
|
||||
const raw: OpenClawSessionHistory = {
|
||||
sessionKey: 'agent:demo:main',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '' as never,
|
||||
...({
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
} as unknown as { content: never }),
|
||||
timestamp: 1000,
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '' as never,
|
||||
...({
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'I should respond with a greeting.',
|
||||
},
|
||||
],
|
||||
} as unknown as { content: never }),
|
||||
timestamp: 1001,
|
||||
},
|
||||
],
|
||||
}
|
||||
const out = convertOpenClawHistoryToAgentHistory('demo', raw)
|
||||
expect(out.items.map((i) => ({ role: i.role, text: i.text }))).toEqual([
|
||||
{ role: 'user', text: 'hi' },
|
||||
])
|
||||
})
|
||||
|
||||
it('drops Subagent Context user messages entirely (no empty bubble)', () => {
|
||||
const raw: OpenClawSessionHistory = {
|
||||
sessionKey: 'agent:demo:main',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '' as never,
|
||||
...({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
'[Subagent Context] You are running as a subagent (depth 1/1).\n\n' +
|
||||
'Begin. Your assigned task is in the system prompt.',
|
||||
},
|
||||
],
|
||||
} as unknown as { content: never }),
|
||||
timestamp: 1000,
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '' as never,
|
||||
...({
|
||||
content: [{ type: 'text', text: 'real reply' }],
|
||||
} as unknown as { content: never }),
|
||||
timestamp: 1001,
|
||||
},
|
||||
],
|
||||
}
|
||||
const out = convertOpenClawHistoryToAgentHistory('demo', raw)
|
||||
expect(out.items.map((i) => ({ role: i.role, text: i.text }))).toEqual([
|
||||
{ role: 'assistant', text: 'real reply' },
|
||||
])
|
||||
})
|
||||
|
||||
it('attaches assistant reasoning and pairs tool call output across messages', () => {
|
||||
const raw: OpenClawSessionHistory = {
|
||||
sessionKey: 'agent:demo:main',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '' as never,
|
||||
...({
|
||||
content: [{ type: 'text', text: 'navigate to example.com' }],
|
||||
} as unknown as { content: never }),
|
||||
timestamp: 1000,
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '' as never,
|
||||
...({
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'I should call the navigate tool.',
|
||||
},
|
||||
{
|
||||
type: 'toolCall',
|
||||
id: 'call-1',
|
||||
name: 'navigate',
|
||||
arguments: { url: 'https://example.com' },
|
||||
},
|
||||
],
|
||||
} as unknown as { content: never }),
|
||||
timestamp: 1001,
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: '' as never,
|
||||
...({
|
||||
content: [
|
||||
{
|
||||
type: 'toolResult',
|
||||
toolCallId: 'call-1',
|
||||
content: 'navigated',
|
||||
},
|
||||
],
|
||||
} as unknown as { content: never }),
|
||||
timestamp: 1002,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const out = convertOpenClawHistoryToAgentHistory('demo', raw)
|
||||
// 'tool' role messages are folded into the prior assistant entry, not surfaced
|
||||
expect(out.items.map((i) => i.role)).toEqual(['user', 'assistant'])
|
||||
const assistant = out.items[1]
|
||||
expect(assistant.reasoning?.text).toBe('I should call the navigate tool.')
|
||||
expect(assistant.toolCalls).toEqual([
|
||||
{
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'navigate',
|
||||
status: 'completed',
|
||||
input: { url: 'https://example.com' },
|
||||
output: 'navigated',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,73 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { mergeEnvContent } from '../../../../src/api/services/openclaw/openclaw-env'
|
||||
import {
|
||||
getHostWorkspaceDir,
|
||||
isAgentWorkspaceNameSafe,
|
||||
mergeEnvContent,
|
||||
} from '../../../../src/api/services/openclaw/openclaw-env'
|
||||
|
||||
describe('isAgentWorkspaceNameSafe', () => {
|
||||
it('accepts plain slugs', () => {
|
||||
expect(isAgentWorkspaceNameSafe('agent-01')).toBe(true)
|
||||
expect(isAgentWorkspaceNameSafe('research_bot')).toBe(true)
|
||||
expect(isAgentWorkspaceNameSafe('My Agent')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty or whitespace-only', () => {
|
||||
expect(isAgentWorkspaceNameSafe('')).toBe(false)
|
||||
expect(isAgentWorkspaceNameSafe(' ')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects path-traversal segments', () => {
|
||||
expect(isAgentWorkspaceNameSafe('..')).toBe(false)
|
||||
expect(isAgentWorkspaceNameSafe('../tmp')).toBe(false)
|
||||
expect(isAgentWorkspaceNameSafe('foo/../bar')).toBe(false)
|
||||
expect(isAgentWorkspaceNameSafe('foo..bar')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects path separators and NULs', () => {
|
||||
expect(isAgentWorkspaceNameSafe('foo/bar')).toBe(false)
|
||||
expect(isAgentWorkspaceNameSafe('foo\\bar')).toBe(false)
|
||||
expect(isAgentWorkspaceNameSafe('foo\0bar')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects names that start with a dot (hidden / dotfile)', () => {
|
||||
expect(isAgentWorkspaceNameSafe('.hidden')).toBe(false)
|
||||
expect(isAgentWorkspaceNameSafe('.')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects control characters', () => {
|
||||
expect(isAgentWorkspaceNameSafe('foo\nbar')).toBe(false)
|
||||
expect(isAgentWorkspaceNameSafe('foo\x07bar')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getHostWorkspaceDir', () => {
|
||||
it("returns the canonical 'main' workspace path", () => {
|
||||
expect(getHostWorkspaceDir('/tmp/openclaw', 'main')).toBe(
|
||||
'/tmp/openclaw/.openclaw/workspace',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a per-agent workspace for safe names', () => {
|
||||
expect(getHostWorkspaceDir('/tmp/openclaw', 'agent-01')).toBe(
|
||||
'/tmp/openclaw/.openclaw/workspace-agent-01',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws for path-traversal names instead of escaping the state dir', () => {
|
||||
expect(() => getHostWorkspaceDir('/tmp/openclaw', '../../etc')).toThrow(
|
||||
/unsafe agent name/i,
|
||||
)
|
||||
})
|
||||
|
||||
it('throws for names containing path separators', () => {
|
||||
expect(() => getHostWorkspaceDir('/tmp/openclaw', 'foo/bar')).toThrow(
|
||||
/unsafe agent name/i,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeEnvContent', () => {
|
||||
it('appends new env keys and normalizes trailing newline', () => {
|
||||
|
||||
@@ -14,10 +14,10 @@ describe('OpenClawHttpClient', () => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
it('checks gateway authentication with the current bearer token', async () => {
|
||||
it('checks no-auth gateway availability without an Authorization header', async () => {
|
||||
const fetchMock = mock(() => Promise.resolve(new Response('{}')))
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await expect(client.isAuthenticated()).resolves.toBe(true)
|
||||
|
||||
@@ -26,17 +26,15 @@ describe('OpenClawHttpClient', () => {
|
||||
)
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Bearer gateway-token',
|
||||
},
|
||||
})
|
||||
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
|
||||
})
|
||||
|
||||
it('treats rejected gateway authentication as unavailable', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('Unauthorized', { status: 401 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await expect(client.isAuthenticated()).resolves.toBe(false)
|
||||
})
|
||||
@@ -45,13 +43,13 @@ describe('OpenClawHttpClient', () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.reject(new Error('connect ECONNREFUSED')),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await expect(client.isAuthenticated()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
describe('getSessionHistory', () => {
|
||||
it('sends GET with bearer auth and forwards limit/cursor as query params', async () => {
|
||||
it('sends GET and forwards limit/cursor as query params', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
@@ -69,7 +67,7 @@ describe('OpenClawHttpClient', () => {
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
const result = await client.getSessionHistory('agent:main:main', {
|
||||
limit: 50,
|
||||
@@ -79,10 +77,8 @@ describe('OpenClawHttpClient', () => {
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'http://127.0.0.1:18789/sessions/agent%3Amain%3Amain/history?limit=50&cursor=abc',
|
||||
)
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer gateway-token' },
|
||||
})
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ method: 'GET' })
|
||||
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
|
||||
expect(result).toEqual({
|
||||
sessionKey: 'agent:main:main',
|
||||
messages: [
|
||||
@@ -94,6 +90,25 @@ describe('OpenClawHttpClient', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('sends no Authorization header when no token provider is configured', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ sessionKey: 'k', messages: [] }), {
|
||||
status: 200,
|
||||
}),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await client.getSessionHistory('k')
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
})
|
||||
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
|
||||
})
|
||||
|
||||
it('omits limit and cursor from the query when undefined', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
@@ -103,7 +118,7 @@ describe('OpenClawHttpClient', () => {
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await client.getSessionHistory('k')
|
||||
|
||||
@@ -116,7 +131,7 @@ describe('OpenClawHttpClient', () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('not found', { status: 404 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await expect(
|
||||
client.getSessionHistory('missing-key'),
|
||||
@@ -127,7 +142,7 @@ describe('OpenClawHttpClient', () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('boom', { status: 500 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await expect(client.getSessionHistory('k')).rejects.toThrow('boom')
|
||||
})
|
||||
@@ -142,7 +157,7 @@ describe('OpenClawHttpClient', () => {
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const controller = new AbortController()
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await client.getSessionHistory('k', { signal: controller.signal })
|
||||
|
||||
@@ -179,7 +194,7 @@ describe('OpenClawHttpClient', () => {
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
const stream = await client.streamSessionHistory('k', { limit: 20 })
|
||||
|
||||
@@ -189,11 +204,9 @@ describe('OpenClawHttpClient', () => {
|
||||
)
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
Authorization: 'Bearer gateway-token',
|
||||
},
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
})
|
||||
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: 'history',
|
||||
@@ -215,6 +228,33 @@ describe('OpenClawHttpClient', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps SSE Accept without Authorization when no token provider is configured', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await client.streamSessionHistory('k')
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
})
|
||||
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
|
||||
})
|
||||
|
||||
it('forwards upstream error frames and closes', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
@@ -234,7 +274,7 @@ describe('OpenClawHttpClient', () => {
|
||||
),
|
||||
),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
const stream = await client.streamSessionHistory('k')
|
||||
|
||||
@@ -247,7 +287,7 @@ describe('OpenClawHttpClient', () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('not found', { status: 404 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await expect(client.streamSessionHistory('k')).rejects.toBeInstanceOf(
|
||||
OpenClawSessionNotFoundError,
|
||||
@@ -284,7 +324,7 @@ describe('OpenClawHttpClient', () => {
|
||||
),
|
||||
),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
const stream = await client.streamSessionHistory('k', {
|
||||
signal: ac.signal,
|
||||
@@ -315,3 +355,10 @@ async function readEvents(
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
function fetchHeaders(
|
||||
fetchMock: ReturnType<typeof mock>,
|
||||
): Record<string, string> {
|
||||
return ((fetchMock.mock.calls[0]?.[1] as RequestInit | undefined)?.headers ??
|
||||
{}) as Record<string, string>
|
||||
}
|
||||
|
||||
@@ -338,7 +338,7 @@ describe('OpenClawService', () => {
|
||||
expect(runOnboard).toHaveBeenCalledWith({
|
||||
acceptRisk: true,
|
||||
authChoice: 'skip',
|
||||
gatewayAuth: 'token',
|
||||
gatewayAuth: 'none',
|
||||
gatewayBind: 'lan',
|
||||
gatewayPort: 18789,
|
||||
installDaemon: false,
|
||||
@@ -372,15 +372,7 @@ describe('OpenClawService', () => {
|
||||
model: undefined,
|
||||
})
|
||||
expect(steps).toEqual(['onboard', 'batch', 'validate', 'start', 'ready'])
|
||||
expect(startGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hostPort: expect.any(Number),
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: undefined,
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(startGateway).toHaveBeenCalledTimes(1)
|
||||
expect(startGateway.mock.calls[0]?.[0]).not.toHaveProperty('image')
|
||||
expect(restartGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -434,66 +426,6 @@ describe('OpenClawService', () => {
|
||||
expect(restartGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads the persisted gateway token from the mounted config before control plane calls', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: 'cli-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.token = 'random-token'
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
}
|
||||
service.cliClient = {
|
||||
listAgents: mock(async () => {
|
||||
expect(service.token).toBe('cli-token')
|
||||
return []
|
||||
}),
|
||||
}
|
||||
|
||||
await service.listAgents()
|
||||
})
|
||||
|
||||
it('caches the loaded gateway token from config across steady-state control plane calls', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: 'cli-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const listAgents = mock(async () => [])
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
}
|
||||
service.cliClient = {
|
||||
listAgents,
|
||||
}
|
||||
|
||||
await service.listAgents()
|
||||
await service.listAgents()
|
||||
|
||||
expect(listAgents).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('writes provider credentials into the mounted state env file during setup', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
@@ -667,15 +599,7 @@ describe('OpenClawService', () => {
|
||||
await service.start()
|
||||
|
||||
expect(ensureReady).toHaveBeenCalledTimes(1)
|
||||
expect(startGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hostPort: expect.any(Number),
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: 'cli-token',
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(startGateway).toHaveBeenCalledTimes(1)
|
||||
expect(waitForReady).toHaveBeenCalledTimes(1)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -882,15 +806,7 @@ describe('OpenClawService', () => {
|
||||
await service.restart()
|
||||
|
||||
expect(ensureReady).toHaveBeenCalledTimes(1)
|
||||
expect(restartGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hostPort: expect.any(Number),
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: 'cli-token',
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(restartGateway).toHaveBeenCalledTimes(1)
|
||||
expect(waitForReady).toHaveBeenCalledTimes(1)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -922,7 +838,8 @@ describe('OpenClawService', () => {
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady: async (hostPort?: number) => hostPort === occupiedPort,
|
||||
// Persisted port is reachable on /readyz; auth pass keeps it.
|
||||
isReady: async () => true,
|
||||
restartGateway,
|
||||
waitForReady,
|
||||
}
|
||||
@@ -933,12 +850,8 @@ describe('OpenClawService', () => {
|
||||
|
||||
await service.restart()
|
||||
|
||||
expect(restartGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hostPort: occupiedPort,
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(restartGateway).toHaveBeenCalledTimes(1)
|
||||
expect(service.getPort()).toBe(occupiedPort)
|
||||
expect(ensureReady).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -969,7 +882,9 @@ describe('OpenClawService', () => {
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady: async (hostPort?: number) => hostPort === occupiedPort,
|
||||
// Persisted port is reachable on the readiness probe; auth
|
||||
// rejection drives the move-off branch.
|
||||
isReady: async () => true,
|
||||
restartGateway,
|
||||
waitForReady,
|
||||
}
|
||||
@@ -980,15 +895,8 @@ describe('OpenClawService', () => {
|
||||
|
||||
await service.restart()
|
||||
|
||||
expect(restartGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hostPort: expect.any(Number),
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(
|
||||
(restartGateway.mock.calls[0]?.[0] as { hostPort: number }).hostPort,
|
||||
).not.toBe(occupiedPort)
|
||||
expect(restartGateway).toHaveBeenCalledTimes(1)
|
||||
expect(service.getPort()).not.toBe(occupiedPort)
|
||||
expect(ensureReady).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -1091,14 +999,7 @@ describe('OpenClawService', () => {
|
||||
await service.tryAutoStart()
|
||||
|
||||
expect(ensureReady).toHaveBeenCalledTimes(1)
|
||||
expect(startGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hostPort: expect.any(Number),
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: 'cli-token',
|
||||
}),
|
||||
)
|
||||
expect(startGateway).toHaveBeenCalledTimes(1)
|
||||
expect(waitForReady).toHaveBeenCalledTimes(1)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
expect(isReady).toHaveBeenCalledTimes(2)
|
||||
@@ -1136,6 +1037,53 @@ describe('OpenClawService', () => {
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('tryAutoStart reuses a ready no-auth gateway without Authorization', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: 'none',
|
||||
token: 'stale-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const ensureReady = mock(async () => {})
|
||||
const isReady = mock(async () => true)
|
||||
const isGatewayCurrent = mock(async () => true)
|
||||
const startGateway = mock(async () => {})
|
||||
const probe = mock(async () => {})
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(new Response('', { status: 200 })),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady,
|
||||
isGatewayCurrent,
|
||||
startGateway,
|
||||
}
|
||||
service.cliClient = { probe }
|
||||
|
||||
await service.tryAutoStart()
|
||||
|
||||
expect(startGateway).not.toHaveBeenCalled()
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'http://127.0.0.1:18789/v1/models',
|
||||
)
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
})
|
||||
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('tryAutoStart recreates a ready gateway when the image is stale', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
@@ -1720,3 +1668,10 @@ function mockGatewayAuth(status = 200): ReturnType<typeof mock> {
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
function fetchHeaders(
|
||||
fetchMock: ReturnType<typeof mock>,
|
||||
): Record<string, string> {
|
||||
return ((fetchMock.mock.calls[0]?.[1] as RequestInit | undefined)?.headers ??
|
||||
{}) as Record<string, string>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { resolveSafeWorkspacePath } from '../../../../src/api/services/openclaw/produced-files-store'
|
||||
|
||||
describe('resolveSafeWorkspacePath', () => {
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
)
|
||||
tempDirs.length = 0
|
||||
})
|
||||
|
||||
it('resolves a regular file inside the workspace', async () => {
|
||||
const root = mkTempDir()
|
||||
const target = join(root, 'output.txt')
|
||||
await writeFile(target, 'hello')
|
||||
|
||||
const resolved = await resolveSafeWorkspacePath(root, 'output.txt')
|
||||
|
||||
expect(resolved).not.toBeNull()
|
||||
expect(resolved).toContain('output.txt')
|
||||
})
|
||||
|
||||
it('resolves a nested file using its workspace-relative path', async () => {
|
||||
const root = mkTempDir()
|
||||
const subdir = join(root, 'reports')
|
||||
await mkdir(subdir, { recursive: true })
|
||||
await writeFile(join(subdir, 'q1.csv'), 'a,b\n1,2')
|
||||
|
||||
const resolved = await resolveSafeWorkspacePath(root, 'reports/q1.csv')
|
||||
|
||||
expect(resolved).not.toBeNull()
|
||||
expect(resolved).toMatch(/reports\/q1\.csv$/)
|
||||
})
|
||||
|
||||
it('rejects lexical traversal with `..` segments', async () => {
|
||||
const root = mkTempDir()
|
||||
// Sibling file lives next to the workspace root so the lexical
|
||||
// join lands on a real, readable file — proving the rejection
|
||||
// is from the containment check, not a missing-file fallback.
|
||||
const siblingDir = mkTempDir()
|
||||
await writeFile(join(siblingDir, 'secret.txt'), 'do not leak')
|
||||
|
||||
const escapingRel = join('..', '..', 'secret.txt')
|
||||
|
||||
const resolved = await resolveSafeWorkspacePath(root, escapingRel)
|
||||
|
||||
expect(resolved).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a symlink whose target lives outside the workspace', async () => {
|
||||
const root = mkTempDir()
|
||||
const outside = mkTempDir()
|
||||
const secret = join(outside, 'passwd')
|
||||
await writeFile(secret, 'shadow:contents')
|
||||
|
||||
// Symlink inside the workspace pointing to the outside file.
|
||||
// The lexical path stays inside the root, but the realpath
|
||||
// resolution should still reject it.
|
||||
await symlink(secret, join(root, 'looks-local'))
|
||||
|
||||
const resolved = await resolveSafeWorkspacePath(root, 'looks-local')
|
||||
|
||||
expect(resolved).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a path that does not exist on disk', async () => {
|
||||
const root = mkTempDir()
|
||||
|
||||
const resolved = await resolveSafeWorkspacePath(root, 'never-created.bin')
|
||||
|
||||
expect(resolved).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the workspace root itself is the resolved path', async () => {
|
||||
const root = mkTempDir()
|
||||
|
||||
// Empty rel-path collapses to the root — must not be downloadable.
|
||||
const resolved = await resolveSafeWorkspacePath(root, '')
|
||||
|
||||
expect(resolved).toBeNull()
|
||||
})
|
||||
|
||||
function mkTempDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'browseros-files-test-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
})
|
||||
@@ -110,4 +110,33 @@ describe('prepareAcpxAgentContext', () => {
|
||||
expect(prepared.runPrompt).not.toContain('AGENT_HOME/MEMORY.md')
|
||||
expect(prepared.runPrompt).not.toContain('Available skills:')
|
||||
})
|
||||
|
||||
it('prepares Hermes with HERMES_HOME pointing at the in-container agent home (translated from the host path)', async () => {
|
||||
const browserosDir = await mkdtemp(join(tmpdir(), 'browseros-adapters-'))
|
||||
tempDirs.push(browserosDir)
|
||||
const prepared = await prepareAcpxAgentContext({
|
||||
browserosDir,
|
||||
agent: makeAgent('hermes'),
|
||||
sessionId: 'main',
|
||||
sessionKey: 'agent:hermes-agent:main',
|
||||
cwdOverride: null,
|
||||
isSelectedCwd: false,
|
||||
message: 'remember this',
|
||||
})
|
||||
|
||||
// HERMES_HOME must be the *container-side* path (under /data) so the
|
||||
// hermes binary running inside the container can actually open it.
|
||||
// The host-side seeded files are reachable via the bind mount.
|
||||
expect(prepared.commandEnv.HERMES_HOME).toBe(
|
||||
'/data/agents/harness/hermes-agent/home',
|
||||
)
|
||||
expect(prepared.commandEnv).not.toHaveProperty('AGENT_HOME')
|
||||
expect(prepared.commandEnv).not.toHaveProperty('CODEX_HOME')
|
||||
expect(prepared.commandEnv).not.toHaveProperty('CLAUDE_CONFIG_DIR')
|
||||
expect(prepared.useBrowserosMcp).toBe(true)
|
||||
expect(prepared.openclawSessionKey).toBeNull()
|
||||
expect(prepared.runtimeSessionKey).toMatch(
|
||||
/^agent:hermes-agent:main:[a-f0-9]{16}$/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,14 @@ import {
|
||||
unwrapBrowserosAcpUserMessage,
|
||||
} from '../../../src/lib/agents/acpx-runtime'
|
||||
import type { AgentDefinition } from '../../../src/lib/agents/agent-types'
|
||||
import {
|
||||
getAgentRuntimeRegistry,
|
||||
HermesContainerRuntime,
|
||||
OpenClawContainerRuntime,
|
||||
resetAgentRuntimeRegistry,
|
||||
} from '../../../src/lib/agents/runtime'
|
||||
import type { AgentStreamEvent } from '../../../src/lib/agents/types'
|
||||
import type { ManagedContainerDeps } from '../../../src/lib/container/managed'
|
||||
|
||||
describe('AcpxRuntime', () => {
|
||||
const tempDirs: string[] = []
|
||||
@@ -31,6 +38,7 @@ describe('AcpxRuntime', () => {
|
||||
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
)
|
||||
tempDirs.length = 0
|
||||
resetAgentRuntimeRegistry()
|
||||
})
|
||||
|
||||
it('uses acpx/runtime to ensure a session and stream a turn', async () => {
|
||||
@@ -590,6 +598,25 @@ just outer
|
||||
expect(unwrapBrowserosAcpUserMessage(outerOnly)).toBe('just outer')
|
||||
})
|
||||
|
||||
it('strips the openclaw single-line role envelope (regression: TKT-774 only matched the BrowserOS multi-line form)', () => {
|
||||
// PR #924 (ACPX agent runtime adapters) introduced a second
|
||||
// `<role>…</role>` prefix for openclaw — a single-line block
|
||||
// distinct from the BrowserOS multi-line role. The original
|
||||
// exact-prefix strip only matched the BrowserOS form, so user
|
||||
// messages from openclaw agents were landing in
|
||||
// /agents/:id/sessions/main/history with the envelope still
|
||||
// attached. The strip must be adapter-agnostic: any
|
||||
// `<role>…</role>` followed by a `<user_request>` block.
|
||||
const wrapped = `<role>You are running inside BrowserOS through the OpenClaw ACP adapter. Use your OpenClaw identity, memory, and browser tools.</role>
|
||||
|
||||
<user_request>
|
||||
Need another report this time as pdf, a comparison between both yahoo and google reports you created...
|
||||
</user_request>`
|
||||
expect(unwrapBrowserosAcpUserMessage(wrapped)).toBe(
|
||||
'Need another report this time as pdf, a comparison between both yahoo and google reports you created...',
|
||||
)
|
||||
})
|
||||
|
||||
it('strips the ACPX runtime envelope when it wraps persisted history', () => {
|
||||
const wrapped = `<browseros_acpx_runtime version="2026-05-02.v1">
|
||||
You are BrowserOS, an ACPX browser agent.
|
||||
@@ -900,6 +927,9 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
expect(command).toContain('env AGENT_HOME=')
|
||||
expect(command).not.toContain('CLAUDE_CONFIG_DIR=')
|
||||
expect(command).not.toContain('CODEX_HOME=')
|
||||
// Spawn must go through acpx-core's npx wrapper for the official
|
||||
// claude-agent-acp package, not a bare `claude` binary.
|
||||
expect(command).toContain('claude-agent-acp')
|
||||
})
|
||||
|
||||
it('injects AGENT_HOME and CODEX_HOME into Codex ACP command resolution', async () => {
|
||||
@@ -934,6 +964,110 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
expect(command).toContain('env AGENT_HOME=')
|
||||
expect(command).toContain('CODEX_HOME=')
|
||||
expect(command).toContain('/runtime/codex-home')
|
||||
// Spawn must go through acpx-core's npx wrapper for the official
|
||||
// codex-acp package, not a bare `codex` binary.
|
||||
expect(command).toContain('codex-acp')
|
||||
})
|
||||
|
||||
it('resolves the Hermes adapter to a container `nerdctl exec hermes acp` command when a HermesContainerRuntime is registered', async () => {
|
||||
const browserosDir = await mkdtemp(
|
||||
join(tmpdir(), 'browseros-acpx-browseros-'),
|
||||
)
|
||||
const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-'))
|
||||
tempDirs.push(browserosDir, stateDir)
|
||||
const fakeManagedDeps: ManagedContainerDeps = {
|
||||
cli: {} as ManagedContainerDeps['cli'],
|
||||
loader: {} as ManagedContainerDeps['loader'],
|
||||
vm: {} as ManagedContainerDeps['vm'],
|
||||
limactlPath: '/opt/homebrew/bin/limactl',
|
||||
limaHome: '/Users/dev/.browseros-dev/lima',
|
||||
vmName: 'browseros-vm',
|
||||
lockDir: stateDir,
|
||||
}
|
||||
const hermesRuntime = new HermesContainerRuntime(fakeManagedDeps, {
|
||||
browserosDir,
|
||||
hermesHarnessHostDir: join(browserosDir, 'vm', 'hermes', 'harness'),
|
||||
})
|
||||
getAgentRuntimeRegistry().register(hermesRuntime)
|
||||
|
||||
const calls: Array<{ method: string; input: unknown }> = []
|
||||
const runtime = new AcpxRuntime({
|
||||
browserosDir,
|
||||
stateDir,
|
||||
runtimeFactory: (options) => {
|
||||
calls.push({ method: 'createRuntime', input: options })
|
||||
return createFakeAcpRuntime(calls)
|
||||
},
|
||||
})
|
||||
const agent = makeAgent({ id: 'agent-1', adapter: 'hermes' })
|
||||
|
||||
await collectStream(
|
||||
await runtime.send({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
sessionKey: agent.sessionKey,
|
||||
message: 'hi',
|
||||
permissionMode: 'approve-all',
|
||||
}),
|
||||
)
|
||||
|
||||
const command =
|
||||
getCreateRuntimeOptions(calls).agentRegistry.resolve('hermes')
|
||||
// Container-spawn path uses limactl shell + nerdctl exec; no host-
|
||||
// process bash/tee workaround (those were Phase A only).
|
||||
expect(command).toContain('env LIMA_HOME=/Users/dev/.browseros-dev/lima')
|
||||
expect(command).toContain(
|
||||
'/opt/homebrew/bin/limactl shell --workdir / browseros-vm --',
|
||||
)
|
||||
expect(command).toContain('nerdctl exec -i')
|
||||
expect(command).toContain('hermes acp')
|
||||
expect(command).toContain('HERMES_HOME=')
|
||||
expect(command).not.toContain('bash -c')
|
||||
expect(command).not.toContain('tee /dev/null')
|
||||
expect(command).not.toContain('AGENT_HOME=')
|
||||
expect(command).not.toContain('CODEX_HOME=')
|
||||
expect(command).not.toContain('CLAUDE_CONFIG_DIR=')
|
||||
})
|
||||
|
||||
it('falls back to a host-process `hermes acp` command when no HermesGatewayAccessor is wired', async () => {
|
||||
const browserosDir = await mkdtemp(
|
||||
join(tmpdir(), 'browseros-acpx-browseros-'),
|
||||
)
|
||||
const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-'))
|
||||
tempDirs.push(browserosDir, stateDir)
|
||||
const calls: Array<{ method: string; input: unknown }> = []
|
||||
const runtime = new AcpxRuntime({
|
||||
browserosDir,
|
||||
stateDir,
|
||||
runtimeFactory: (options) => {
|
||||
calls.push({ method: 'createRuntime', input: options })
|
||||
return createFakeAcpRuntime(calls)
|
||||
},
|
||||
})
|
||||
const agent = makeAgent({ id: 'agent-1', adapter: 'hermes' })
|
||||
|
||||
await collectStream(
|
||||
await runtime.send({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
sessionKey: agent.sessionKey,
|
||||
message: 'hi',
|
||||
permissionMode: 'approve-all',
|
||||
}),
|
||||
)
|
||||
|
||||
const command =
|
||||
getCreateRuntimeOptions(calls).agentRegistry.resolve('hermes')
|
||||
// Host-process fallback: bare `hermes acp` with HERMES_HOME injected
|
||||
// via wrapCommandWithEnv. No limactl/nerdctl chain — used by tests
|
||||
// and as a defensive escape hatch when the container service hasn't
|
||||
// been wired yet.
|
||||
expect(command).toContain('hermes acp')
|
||||
expect(command).toContain('env HERMES_HOME=')
|
||||
expect(command).not.toContain('limactl')
|
||||
expect(command).not.toContain('nerdctl')
|
||||
expect(command).not.toContain('bash -c')
|
||||
expect(command).not.toContain('tee /dev/null')
|
||||
})
|
||||
|
||||
it('does not reuse an Acpx runtime across different command identities', async () => {
|
||||
@@ -979,17 +1113,15 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
})
|
||||
|
||||
it('resolves the openclaw adapter to a lima/nerdctl exec command', async () => {
|
||||
registerFakeOpenClawRuntime({
|
||||
limactlPath: '/opt/homebrew/bin/limactl',
|
||||
limaHome: '/Users/dev/.browseros-dev/lima',
|
||||
vmName: 'browseros-vm',
|
||||
})
|
||||
const calls: Array<{ method: string; input: unknown }> = []
|
||||
const runtime = new AcpxRuntime({
|
||||
cwd: '/tmp/browseros-acpx-runtime',
|
||||
stateDir: '/tmp/browseros-acpx-state',
|
||||
openclawGateway: {
|
||||
getGatewayToken: () => 'test-token-abc',
|
||||
getContainerName: () => 'browseros-openclaw-openclaw-gateway-1',
|
||||
getLimaHomeDir: () => '/Users/dev/.browseros-dev/lima',
|
||||
getLimactlPath: () => '/opt/homebrew/bin/limactl',
|
||||
getVmName: () => 'browseros-vm',
|
||||
},
|
||||
runtimeFactory: (options) => {
|
||||
calls.push({ method: 'createRuntime', input: options })
|
||||
return createFakeAcpRuntime(calls)
|
||||
@@ -1024,9 +1156,8 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
expect(command).toContain(
|
||||
'nerdctl exec -i -e OPENCLAW_HIDE_BANNER=1 -e OPENCLAW_SUPPRESS_NOTES=1 browseros-openclaw-openclaw-gateway-1',
|
||||
)
|
||||
expect(command).toContain(
|
||||
'openclaw acp --url ws://127.0.0.1:18789 --token test-token-abc',
|
||||
)
|
||||
expect(command).toContain('openclaw acp --url ws://127.0.0.1:18789')
|
||||
expect(command).not.toContain('--token')
|
||||
// sessionKey routing: the bridge needs --session <key> to map newSession
|
||||
// requests to the matching gateway agent (acpx does not forward
|
||||
// sessionKey via ACP newSession params).
|
||||
@@ -1039,17 +1170,15 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
})
|
||||
|
||||
it('rewrites non-harness OpenClaw session keys onto the gateway main agent', async () => {
|
||||
registerFakeOpenClawRuntime({
|
||||
limactlPath: '/opt/homebrew/bin/limactl',
|
||||
limaHome: '/Users/dev/.browseros-dev/lima',
|
||||
vmName: 'browseros-vm',
|
||||
})
|
||||
const calls: Array<{ method: string; input: unknown }> = []
|
||||
const runtime = new AcpxRuntime({
|
||||
cwd: '/tmp/browseros-acpx-runtime',
|
||||
stateDir: '/tmp/browseros-acpx-state',
|
||||
openclawGateway: {
|
||||
getGatewayToken: () => 'test-token-abc',
|
||||
getContainerName: () => 'browseros-openclaw-openclaw-gateway-1',
|
||||
getLimaHomeDir: () => '/Users/dev/.browseros-dev/lima',
|
||||
getLimactlPath: () => '/opt/homebrew/bin/limactl',
|
||||
getVmName: () => 'browseros-vm',
|
||||
},
|
||||
runtimeFactory: (options) => {
|
||||
calls.push({ method: 'createRuntime', input: options })
|
||||
return createFakeAcpRuntime(calls)
|
||||
@@ -1234,144 +1363,32 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
.map((call) => (call.input as { timeoutMs?: number }).timeoutMs),
|
||||
).toEqual([1_000, 2_000])
|
||||
})
|
||||
|
||||
it('diverts OpenClaw image turns to the gateway chat client and persists them to the session record', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'browseros-acpx-runtime-'))
|
||||
const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-'))
|
||||
tempDirs.push(cwd, stateDir)
|
||||
// Pre-seed the session record so persistence has somewhere to land.
|
||||
// (First-turn-image-only sessions deliberately skip persistence; that
|
||||
// path is covered by the empty-record test below.)
|
||||
const sessionStore = createRuntimeStore({ stateDir })
|
||||
const seedTimestamp = '2026-04-28T20:00:00.000Z'
|
||||
const seedRecord: AcpSessionRecord = {
|
||||
schema: 'acpx.session.v1',
|
||||
acpxRecordId: 'agent:img-bot:main',
|
||||
acpSessionId: 'sid-img',
|
||||
agentSessionId: 'inner-img',
|
||||
agentCommand: 'env LIMA_HOME=/tmp limactl shell vm -- nerdctl exec',
|
||||
cwd,
|
||||
name: 'agent:img-bot:main',
|
||||
createdAt: seedTimestamp,
|
||||
lastUsedAt: seedTimestamp,
|
||||
lastSeq: 0,
|
||||
eventLog: {
|
||||
active_path: '',
|
||||
segment_count: 0,
|
||||
max_segment_bytes: 0,
|
||||
max_segments: 0,
|
||||
},
|
||||
closed: false,
|
||||
messages: [
|
||||
{
|
||||
User: {
|
||||
id: 'prior-user',
|
||||
content: [{ Text: 'literal & <tag>' } as never],
|
||||
},
|
||||
},
|
||||
{ Agent: { content: [{ Text: 'Prior answer.' }], tool_results: {} } },
|
||||
],
|
||||
updated_at: seedTimestamp,
|
||||
cumulative_token_usage: {},
|
||||
request_token_usage: {},
|
||||
acpx: {},
|
||||
}
|
||||
await sessionStore.save(seedRecord)
|
||||
|
||||
const gatewayCalls: Array<{ method: string; input: unknown }> = []
|
||||
const openclawGatewayChat = {
|
||||
streamTurn: async (input: unknown) => {
|
||||
gatewayCalls.push({ method: 'streamTurn', input })
|
||||
return new ReadableStream<AgentStreamEvent>({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: 'text_delta',
|
||||
text: 'Red.',
|
||||
stream: 'output',
|
||||
})
|
||||
controller.enqueue({ type: 'done', stopReason: 'end_turn' })
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
},
|
||||
} as never
|
||||
const calls: Array<{ method: string; input: unknown }> = []
|
||||
const runtime = new AcpxRuntime({
|
||||
cwd,
|
||||
stateDir,
|
||||
openclawGatewayChat,
|
||||
// Provide a runtime factory that would fail loudly if reached —
|
||||
// image turns must NOT fall through to the ACP path.
|
||||
runtimeFactory: (options) => {
|
||||
calls.push({ method: 'createRuntime', input: options })
|
||||
throw new Error('ACP path should not be reached for image turns')
|
||||
},
|
||||
})
|
||||
|
||||
const agent: AgentDefinition = {
|
||||
id: 'img-bot',
|
||||
name: 'OpenClaw image bot',
|
||||
adapter: 'openclaw',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: 'agent:img-bot:main',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
}
|
||||
|
||||
const events = await collectStream(
|
||||
await runtime.send({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
sessionKey: agent.sessionKey,
|
||||
message: 'What color is this?',
|
||||
attachments: [{ mediaType: 'image/png', data: 'BASE64DATA' }],
|
||||
permissionMode: 'approve-all',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: 'text_delta', text: 'Red.', stream: 'output' },
|
||||
{ type: 'done', stopReason: 'end_turn' },
|
||||
])
|
||||
expect(gatewayCalls).toHaveLength(1)
|
||||
expect(
|
||||
calls.filter((call) => call.method === 'createRuntime'),
|
||||
).toHaveLength(0)
|
||||
const gatewayInput = gatewayCalls[0]?.input as {
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
messages: Array<{
|
||||
role: string
|
||||
content: string | Array<{ type: string }>
|
||||
}>
|
||||
}
|
||||
expect(gatewayInput.agentId).toBe('img-bot')
|
||||
expect(gatewayInput.messages[0]).toEqual({
|
||||
role: 'user',
|
||||
content: 'literal & <tag>',
|
||||
})
|
||||
expect(gatewayInput.messages.at(-1)?.role).toBe('user')
|
||||
const userContent = gatewayInput.messages.at(-1)?.content
|
||||
expect(Array.isArray(userContent)).toBe(true)
|
||||
expect(
|
||||
(userContent as Array<{ type: string }>).filter(
|
||||
(p) => p.type === 'image_url',
|
||||
),
|
||||
).toHaveLength(1)
|
||||
|
||||
// Persistence check: history should now show the user+assistant turn.
|
||||
const history = await runtime.getHistory({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
})
|
||||
expect(history.items.slice(-2).map((item) => item.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
])
|
||||
expect(history.items.at(-1)?.text).toBe('Red.')
|
||||
})
|
||||
})
|
||||
|
||||
function registerFakeOpenClawRuntime(opts: {
|
||||
limactlPath: string
|
||||
limaHome: string
|
||||
vmName: string
|
||||
}): OpenClawContainerRuntime {
|
||||
resetAgentRuntimeRegistry()
|
||||
const fakeDeps: ManagedContainerDeps = {
|
||||
cli: {} as ManagedContainerDeps['cli'],
|
||||
loader: {} as ManagedContainerDeps['loader'],
|
||||
vm: {} as ManagedContainerDeps['vm'],
|
||||
limactlPath: opts.limactlPath,
|
||||
limaHome: opts.limaHome,
|
||||
vmName: opts.vmName,
|
||||
lockDir: '/tmp/openclaw-test-locks',
|
||||
}
|
||||
const runtime = new OpenClawContainerRuntime(fakeDeps, {
|
||||
browserosDir: '/tmp/browseros-test',
|
||||
openclawDir: '/tmp/browseros-test/vm/openclaw',
|
||||
})
|
||||
runtime.setHostPort(18789)
|
||||
getAgentRuntimeRegistry().register(runtime)
|
||||
return runtime
|
||||
}
|
||||
|
||||
function makeAgent(input: {
|
||||
id: string
|
||||
adapter: AgentDefinition['adapter']
|
||||
|
||||
@@ -12,11 +12,12 @@ import {
|
||||
} from '../../../src/lib/agents/agent-catalog'
|
||||
|
||||
describe('AGENT_ADAPTER_CATALOG', () => {
|
||||
it('exposes Claude, Codex, and OpenClaw adapters with model and effort options', () => {
|
||||
it('exposes Claude, Codex, OpenClaw, and Hermes adapters with model and effort options', () => {
|
||||
expect(AGENT_ADAPTER_CATALOG.map((adapter) => adapter.id)).toEqual([
|
||||
'claude',
|
||||
'codex',
|
||||
'openclaw',
|
||||
'hermes',
|
||||
])
|
||||
|
||||
expect(getAgentAdapterDescriptor('claude')).toMatchObject({
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
ClaudeRuntime,
|
||||
configureClaudeRuntime,
|
||||
getAgentRuntimeRegistry,
|
||||
getClaudeRuntime,
|
||||
prepareClaudeCodeContext,
|
||||
resetAgentRuntimeRegistry,
|
||||
} from '../../../../src/lib/agents/runtime'
|
||||
|
||||
function makeAgent(id = 'agent-1') {
|
||||
return {
|
||||
id,
|
||||
name: 'Claude bot',
|
||||
adapter: 'claude' as const,
|
||||
sessionKey: `agent:${id}:main`,
|
||||
pinned: false,
|
||||
updatedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
modelId: 'claude-opus-4-5',
|
||||
reasoningEffort: 'medium',
|
||||
providerType: 'host-auth',
|
||||
providerName: null,
|
||||
baseUrl: null,
|
||||
apiKey: null,
|
||||
supportsImages: true,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ClaudeRuntime', () => {
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
)
|
||||
tempDirs.length = 0
|
||||
resetAgentRuntimeRegistry()
|
||||
})
|
||||
|
||||
it('declares the canonical Claude descriptor', () => {
|
||||
const runtime = new ClaudeRuntime(
|
||||
{ binaryName: 'claude' },
|
||||
{ browserosDir: '/tmp/browseros' },
|
||||
)
|
||||
expect(runtime.descriptor.adapterId).toBe('claude')
|
||||
expect(runtime.descriptor.kind).toBe('host-process')
|
||||
expect(runtime.descriptor.platforms).toContain('darwin')
|
||||
expect(runtime.descriptor.platforms).toContain('linux')
|
||||
})
|
||||
|
||||
it('getPerAgentHomeDir resolves the canonical agent home path', () => {
|
||||
const runtime = new ClaudeRuntime(
|
||||
{ binaryName: 'claude' },
|
||||
{ browserosDir: '/tmp/browseros' },
|
||||
)
|
||||
expect(runtime.getPerAgentHomeDir('agent-7')).toBe(
|
||||
'/tmp/browseros/agents/harness/agent-7/home',
|
||||
)
|
||||
})
|
||||
|
||||
it('prepareTurnContext sets AGENT_HOME and not CODEX_HOME', async () => {
|
||||
const browserosDir = await mkdtemp(join(tmpdir(), 'browseros-claude-'))
|
||||
tempDirs.push(browserosDir)
|
||||
const prepared = await prepareClaudeCodeContext({
|
||||
browserosDir,
|
||||
agent: makeAgent('claude-agent'),
|
||||
sessionId: 'main',
|
||||
sessionKey: 'agent:claude-agent:main',
|
||||
cwdOverride: null,
|
||||
isSelectedCwd: false,
|
||||
message: 'hi',
|
||||
})
|
||||
expect(prepared.commandEnv).toEqual({
|
||||
AGENT_HOME: join(
|
||||
browserosDir,
|
||||
'agents',
|
||||
'harness',
|
||||
'claude-agent',
|
||||
'home',
|
||||
),
|
||||
})
|
||||
expect(prepared.commandEnv).not.toHaveProperty('CODEX_HOME')
|
||||
expect(prepared.useBrowserosMcp).toBe(true)
|
||||
})
|
||||
|
||||
describe('configureClaudeRuntime', () => {
|
||||
it('registers a runtime in the registry', () => {
|
||||
const browserosDir = '/tmp/browseros'
|
||||
const runtime = configureClaudeRuntime({ browserosDir })
|
||||
expect(runtime).toBeInstanceOf(ClaudeRuntime)
|
||||
expect(getClaudeRuntime()).toBe(runtime)
|
||||
expect(getAgentRuntimeRegistry().get('claude')).toBe(runtime)
|
||||
})
|
||||
|
||||
it('throws on duplicate registration', () => {
|
||||
configureClaudeRuntime({ browserosDir: '/tmp/browseros' })
|
||||
expect(() =>
|
||||
configureClaudeRuntime({ browserosDir: '/tmp/browseros' }),
|
||||
).toThrow(/already registered/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
CodexRuntime,
|
||||
configureCodexRuntime,
|
||||
getAgentRuntimeRegistry,
|
||||
getCodexRuntime,
|
||||
prepareCodexContext,
|
||||
resetAgentRuntimeRegistry,
|
||||
} from '../../../../src/lib/agents/runtime'
|
||||
|
||||
function makeAgent(id = 'agent-1') {
|
||||
return {
|
||||
id,
|
||||
name: 'Codex bot',
|
||||
adapter: 'codex' as const,
|
||||
sessionKey: `agent:${id}:main`,
|
||||
pinned: false,
|
||||
updatedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
modelId: 'gpt-5.5',
|
||||
reasoningEffort: 'medium',
|
||||
providerType: 'host-auth',
|
||||
providerName: null,
|
||||
baseUrl: null,
|
||||
apiKey: null,
|
||||
supportsImages: false,
|
||||
}
|
||||
}
|
||||
|
||||
describe('CodexRuntime', () => {
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
)
|
||||
tempDirs.length = 0
|
||||
resetAgentRuntimeRegistry()
|
||||
})
|
||||
|
||||
it('declares the canonical Codex descriptor', () => {
|
||||
const runtime = new CodexRuntime(
|
||||
{ binaryName: 'codex' },
|
||||
{ browserosDir: '/tmp/browseros' },
|
||||
)
|
||||
expect(runtime.descriptor.adapterId).toBe('codex')
|
||||
expect(runtime.descriptor.kind).toBe('host-process')
|
||||
})
|
||||
|
||||
it('prepareTurnContext sets AGENT_HOME + CODEX_HOME and materializes codex home', async () => {
|
||||
const browserosDir = await mkdtemp(join(tmpdir(), 'browseros-codex-'))
|
||||
tempDirs.push(browserosDir)
|
||||
const prepared = await prepareCodexContext({
|
||||
browserosDir,
|
||||
agent: makeAgent('codex-agent'),
|
||||
sessionId: 'main',
|
||||
sessionKey: 'agent:codex-agent:main',
|
||||
cwdOverride: null,
|
||||
isSelectedCwd: false,
|
||||
message: 'hi',
|
||||
})
|
||||
expect(prepared.commandEnv.AGENT_HOME).toBe(
|
||||
join(browserosDir, 'agents', 'harness', 'codex-agent', 'home'),
|
||||
)
|
||||
expect(prepared.commandEnv.CODEX_HOME).toBe(
|
||||
join(
|
||||
browserosDir,
|
||||
'agents',
|
||||
'harness',
|
||||
'codex-agent',
|
||||
'runtime',
|
||||
'codex-home',
|
||||
),
|
||||
)
|
||||
const codexHomeStat = await stat(prepared.commandEnv.CODEX_HOME)
|
||||
expect(codexHomeStat.isDirectory()).toBe(true)
|
||||
expect(prepared.useBrowserosMcp).toBe(true)
|
||||
})
|
||||
|
||||
describe('configureCodexRuntime', () => {
|
||||
it('registers a runtime in the registry', () => {
|
||||
const runtime = configureCodexRuntime({ browserosDir: '/tmp/browseros' })
|
||||
expect(runtime).toBeInstanceOf(CodexRuntime)
|
||||
expect(getCodexRuntime()).toBe(runtime)
|
||||
expect(getAgentRuntimeRegistry().get('codex')).toBe(runtime)
|
||||
})
|
||||
|
||||
it('throws on duplicate registration', () => {
|
||||
configureCodexRuntime({ browserosDir: '/tmp/browseros' })
|
||||
expect(() =>
|
||||
configureCodexRuntime({ browserosDir: '/tmp/browseros' }),
|
||||
).toThrow(/already registered/)
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user