mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 15:46:22 +00:00
feat(agent): run claude and codex in VM containers
This commit is contained in:
@@ -30,6 +30,7 @@ interface AgentListProps {
|
||||
deletingAgentKey: string | null
|
||||
onCreateAgent: () => void
|
||||
onDeleteAgent: (agent: AgentListItem) => void
|
||||
onOpenTerminal?: (agent: AgentListItem) => void
|
||||
onPinToggle: (agent: AgentListItem, next: boolean) => void
|
||||
}
|
||||
|
||||
@@ -42,6 +43,7 @@ export const AgentList: FC<AgentListProps> = ({
|
||||
deletingAgentKey,
|
||||
onCreateAgent,
|
||||
onDeleteAgent,
|
||||
onOpenTerminal,
|
||||
onPinToggle,
|
||||
}) => {
|
||||
const adapterHealth = useMemo(() => {
|
||||
@@ -104,6 +106,7 @@ export const AgentList: FC<AgentListProps> = ({
|
||||
data={data}
|
||||
deleting={deletingAgentKey === agent.key}
|
||||
onDelete={onDeleteAgent}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
onPinToggle={onPinToggle}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -33,6 +33,7 @@ export const AgentRowCard: FC<AgentRowCardProps> = ({
|
||||
data,
|
||||
deleting,
|
||||
onDelete,
|
||||
onOpenTerminal,
|
||||
onPinToggle,
|
||||
}) => {
|
||||
return (
|
||||
@@ -92,6 +93,7 @@ export const AgentRowCard: FC<AgentRowCardProps> = ({
|
||||
activeTurnId={data.activeTurnId}
|
||||
deleting={deleting}
|
||||
onDelete={onDelete}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,16 +13,28 @@ import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
|
||||
interface AgentTerminalProps {
|
||||
onBack: () => void
|
||||
target?: TerminalTargetId
|
||||
agentId?: string
|
||||
initialCommand?: string
|
||||
onSessionExit?: () => void
|
||||
}
|
||||
|
||||
type TerminalTargetId = 'openclaw' | 'claude' | 'codex' | 'hermes'
|
||||
|
||||
interface TerminalTargetOption {
|
||||
id: TerminalTargetId
|
||||
label: string
|
||||
workingDir: string
|
||||
shell: string
|
||||
}
|
||||
|
||||
type TerminalServerMessage =
|
||||
| { type: 'output'; data: string }
|
||||
| { type: 'exit'; exitCode: number }
|
||||
| { type: 'error'; message: string }
|
||||
|
||||
const TERMINAL_HOME_DIR = OPENCLAW_CONTAINER_HOME
|
||||
const DEFAULT_TARGET: TerminalTargetId = 'openclaw'
|
||||
const TERMINAL_FONT_FAMILY =
|
||||
'"Geist Mono", Menlo, Monaco, "Courier New", monospace'
|
||||
|
||||
@@ -118,11 +130,14 @@ function parseTerminalMessage(data: unknown): TerminalServerMessage | null {
|
||||
|
||||
export const AgentTerminal: FC<AgentTerminalProps> = ({
|
||||
onBack,
|
||||
target = DEFAULT_TARGET,
|
||||
agentId,
|
||||
initialCommand,
|
||||
onSessionExit,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
const sentInitialCommandRef = useRef(false)
|
||||
// Refs keep the mount-once effect from tearing down the PTY when the
|
||||
// parent re-renders with new inline callbacks.
|
||||
const initialCommandRef = useRef(initialCommand)
|
||||
@@ -131,6 +146,45 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
|
||||
onSessionExitRef.current = onSessionExit
|
||||
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [selectedTarget, setSelectedTarget] = useState<TerminalTargetId>(target)
|
||||
const [targets, setTargets] = useState<TerminalTargetOption[]>([])
|
||||
|
||||
const selectedTargetInfo = targets.find(
|
||||
(entry) => entry.id === selectedTarget,
|
||||
)
|
||||
const workingDir = selectedTargetInfo?.workingDir ?? TERMINAL_HOME_DIR
|
||||
const shell = selectedTargetInfo?.shell ?? OPENCLAW_TERMINAL_SHELL
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTarget(target)
|
||||
}, [target])
|
||||
|
||||
useEffect(() => {
|
||||
const ac = new AbortController()
|
||||
const loadTargets = async (): Promise<void> => {
|
||||
try {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
if (ac.signal.aborted) return
|
||||
const url = new URL('/terminal/targets', baseUrl)
|
||||
if (agentId) url.searchParams.set('agentId', agentId)
|
||||
const res = await fetch(url, { signal: ac.signal })
|
||||
if (!res.ok) return
|
||||
const body = (await res.json()) as { targets?: TerminalTargetOption[] }
|
||||
const nextTargets = body.targets ?? []
|
||||
setTargets(nextTargets)
|
||||
setSelectedTarget((current) =>
|
||||
nextTargets.length > 0 &&
|
||||
!nextTargets.some((entry) => entry.id === current)
|
||||
? nextTargets[0].id
|
||||
: current,
|
||||
)
|
||||
} catch {
|
||||
if (!ac.signal.aborted) setTargets([])
|
||||
}
|
||||
}
|
||||
void loadTargets()
|
||||
return () => ac.abort()
|
||||
}, [agentId])
|
||||
|
||||
// Copy the current xterm selection to the browser clipboard. No-op
|
||||
// if nothing is selected — users who want the whole buffer can
|
||||
@@ -152,6 +206,8 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
sentInitialCommandRef.current = false
|
||||
|
||||
const terminal = new Terminal({
|
||||
fontSize: 14,
|
||||
fontFamily: TERMINAL_FONT_FAMILY,
|
||||
@@ -226,6 +282,8 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
|
||||
if (ac.signal.aborted) return
|
||||
const wsUrl = new URL('/terminal/ws', baseUrl)
|
||||
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
wsUrl.searchParams.set('target', selectedTarget)
|
||||
if (agentId) wsUrl.searchParams.set('agentId', agentId)
|
||||
|
||||
ws = new WebSocket(wsUrl)
|
||||
// If the effect was cleaned up between the await above and now,
|
||||
@@ -242,7 +300,10 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
|
||||
terminal.focus()
|
||||
sendResize()
|
||||
const cmd = initialCommandRef.current
|
||||
if (cmd) sendMessage({ type: 'input', data: `${cmd}\n` })
|
||||
if (cmd && !sentInitialCommandRef.current) {
|
||||
sentInitialCommandRef.current = true
|
||||
sendMessage({ type: 'input', data: `${cmd}\n` })
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
@@ -303,7 +364,7 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
|
||||
terminal.dispose()
|
||||
terminalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
}, [agentId, selectedTarget])
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100dvh-10rem)] min-h-[32rem] w-full flex-col py-2 sm:min-h-[42rem] sm:py-4">
|
||||
@@ -318,10 +379,25 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
|
||||
Container Terminal
|
||||
</div>
|
||||
<div className="truncate text-muted-foreground text-sm">
|
||||
OpenClaw shell in {TERMINAL_HOME_DIR}
|
||||
{selectedTargetInfo?.label ?? 'Managed runtime'} shell
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{targets.length > 1 && (
|
||||
<select
|
||||
value={selectedTarget}
|
||||
onChange={(event) =>
|
||||
setSelectedTarget(event.currentTarget.value as TerminalTargetId)
|
||||
}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
|
||||
>
|
||||
{targets.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||
{copied ? (
|
||||
<Check className="mr-1 size-3.5" />
|
||||
@@ -336,10 +412,10 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
|
||||
<div className="agent-terminal-shell flex h-full min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-background">
|
||||
<div className="flex items-center justify-between gap-3 border-border border-b px-4 py-2.5">
|
||||
<div className="truncate font-mono text-muted-foreground text-xs">
|
||||
{TERMINAL_HOME_DIR}
|
||||
{workingDir}
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-muted-foreground">
|
||||
{OPENCLAW_TERMINAL_SHELL.split('/').pop()}
|
||||
{shell.split('/').pop()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -49,6 +49,14 @@ import {
|
||||
} from './useAgents'
|
||||
import { useOpenClawAgents, useOpenClawMutations } from './useOpenClaw'
|
||||
|
||||
type TerminalTargetId = 'openclaw' | 'claude' | 'codex' | 'hermes'
|
||||
|
||||
interface TerminalLaunch {
|
||||
target: TerminalTargetId
|
||||
agentId?: string
|
||||
initialCommand?: string
|
||||
}
|
||||
|
||||
export const AgentsPage: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { providers, defaultProviderId } = useLlmProviders()
|
||||
@@ -108,7 +116,9 @@ export const AgentsPage: FC = () => {
|
||||
const [harnessModelId, setHarnessModelId] = useState('')
|
||||
const [harnessReasoningEffort, setHarnessReasoningEffort] = useState('')
|
||||
const [createHermesProviderId, setCreateHermesProviderId] = useState('')
|
||||
const [showTerminal, setShowTerminal] = useState(false)
|
||||
const [terminalLaunch, setTerminalLaunch] = useState<TerminalLaunch | null>(
|
||||
null,
|
||||
)
|
||||
const [cliAuthModalOpen, setCliAuthModalOpen] = useState(false)
|
||||
const [pageError, setPageError] = useState<string | null>(null)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
@@ -232,6 +242,20 @@ export const AgentsPage: FC = () => {
|
||||
setHarnessReasoningEffort(descriptor?.defaultReasoningEffort ?? '')
|
||||
}
|
||||
|
||||
const handleOpenAgentTerminal = (agent: {
|
||||
agentId: string
|
||||
runtimeLabel: string
|
||||
}) => {
|
||||
const target =
|
||||
harnessAgentLookup.get(agent.agentId)?.adapter ??
|
||||
inferTerminalTarget(agent.runtimeLabel)
|
||||
if (!target) return
|
||||
setTerminalLaunch({
|
||||
target,
|
||||
agentId: agent.agentId,
|
||||
})
|
||||
}
|
||||
|
||||
const { handleCreate, handleDelete, handleSetup, runWithPageErrorHandling } =
|
||||
createAgentPageActions({
|
||||
createProviderId,
|
||||
@@ -258,14 +282,22 @@ export const AgentsPage: FC = () => {
|
||||
setupOpenClaw,
|
||||
})
|
||||
|
||||
if (showTerminal) {
|
||||
return <AgentTerminal onBack={() => setShowTerminal(false)} />
|
||||
if (terminalLaunch) {
|
||||
return (
|
||||
<AgentTerminal
|
||||
onBack={() => setTerminalLaunch(null)}
|
||||
target={terminalLaunch.target}
|
||||
agentId={terminalLaunch.agentId}
|
||||
initialCommand={terminalLaunch.initialCommand}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (cliAuthModalOpen && authTerminalProvider) {
|
||||
return (
|
||||
<AgentTerminal
|
||||
onBack={() => setCliAuthModalOpen(false)}
|
||||
target="openclaw"
|
||||
initialCommand={authTerminalProvider.authLoginCommand}
|
||||
onSessionExit={() => setCliAuthModalOpen(false)}
|
||||
/>
|
||||
@@ -345,7 +377,7 @@ export const AgentsPage: FC = () => {
|
||||
<GatewayStatusBar
|
||||
status={status}
|
||||
actionInProgress={actionInProgress}
|
||||
onOpenTerminal={() => setShowTerminal(true)}
|
||||
onOpenTerminal={() => setTerminalLaunch({ target: 'openclaw' })}
|
||||
onRestart={() => {
|
||||
void runWithPageErrorHandling(restartOpenClaw)
|
||||
}}
|
||||
@@ -363,6 +395,7 @@ export const AgentsPage: FC = () => {
|
||||
onDeleteAgent={(agent) => {
|
||||
void handleDelete(agent)
|
||||
}}
|
||||
onOpenTerminal={handleOpenAgentTerminal}
|
||||
onPinToggle={(agent, next) => {
|
||||
// Optimistic mutation; harness-only — gateway-original
|
||||
// OpenClaw entries are gated server-side via the harness
|
||||
@@ -430,3 +463,12 @@ export const AgentsPage: FC = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function inferTerminalTarget(label: string): TerminalTargetId | null {
|
||||
const lower = label.toLowerCase()
|
||||
if (lower === 'claude code') return 'claude'
|
||||
if (lower === 'codex') return 'codex'
|
||||
if (lower === 'hermes') return 'hermes'
|
||||
if (lower === 'openclaw') return 'openclaw'
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import type { HarnessAdapterDescriptor } from './agent-harness-types'
|
||||
import { getAdapterReadinessAlert } from './new-agent-dialog.helpers'
|
||||
|
||||
const baseAdapter: HarnessAdapterDescriptor = {
|
||||
id: 'claude',
|
||||
name: 'Claude Code',
|
||||
defaultModelId: 'default',
|
||||
defaultReasoningEffort: 'medium',
|
||||
modelControl: 'best-effort',
|
||||
models: [],
|
||||
reasoningEfforts: [],
|
||||
}
|
||||
|
||||
describe('getAdapterReadinessAlert', () => {
|
||||
it('blocks creation and explains the selected unhealthy runtime', () => {
|
||||
expect(
|
||||
getAdapterReadinessAlert({
|
||||
...baseAdapter,
|
||||
health: {
|
||||
healthy: false,
|
||||
reason: 'Container is stopped. Call start() first.',
|
||||
checkedAt: 123,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
title: 'Claude Code runtime is not ready',
|
||||
description: 'Container is stopped. Call start() first.',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not warn for healthy adapters', () => {
|
||||
expect(
|
||||
getAdapterReadinessAlert({
|
||||
...baseAdapter,
|
||||
health: { healthy: true, checkedAt: 123 },
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
HarnessAgentAdapter,
|
||||
} from './agent-harness-types'
|
||||
import type { CreateAgentRuntime, ProviderOption } from './agents-page-types'
|
||||
import { getAdapterReadinessAlert } from './new-agent-dialog.helpers'
|
||||
import { ProviderSelector } from './OpenClawControls'
|
||||
import {
|
||||
type OpenClawCliProvider,
|
||||
@@ -95,6 +96,11 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
}) => {
|
||||
const selectedHarnessAdapter =
|
||||
adapters.find((adapter) => adapter.id === harnessAdapterId) ?? adapters[0]
|
||||
const selectedRuntimeAdapter =
|
||||
createRuntime === 'openclaw'
|
||||
? undefined
|
||||
: adapters.find((adapter) => adapter.id === createRuntime)
|
||||
const adapterReadinessAlert = getAdapterReadinessAlert(selectedRuntimeAdapter)
|
||||
const isHarnessRuntime = createRuntime !== 'openclaw'
|
||||
const isHermesRuntime = createRuntime === 'hermes'
|
||||
const isClassicHarnessRuntime = isHarnessRuntime && !isHermesRuntime
|
||||
@@ -112,6 +118,7 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
!openClawBlocked &&
|
||||
!cliBlocked &&
|
||||
!hermesBlocked &&
|
||||
!adapterReadinessAlert &&
|
||||
(createRuntime === 'openclaw'
|
||||
? providers.length > 0
|
||||
: Boolean(selectedHarnessAdapter))
|
||||
@@ -176,6 +183,16 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{adapterReadinessAlert ? (
|
||||
<Alert>
|
||||
<AlertCircle className="size-4" />
|
||||
<AlertTitle>{adapterReadinessAlert.title}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{adapterReadinessAlert.description}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{createRuntime === 'openclaw' ? (
|
||||
<>
|
||||
{openClawBlocked ? (
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
Terminal,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
@@ -36,6 +37,7 @@ interface AgentActionsProps {
|
||||
activeTurnId: string | null
|
||||
deleting?: boolean
|
||||
onDelete: (agent: AgentListItem) => void
|
||||
onOpenTerminal?: (agent: AgentListItem) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +52,7 @@ export const AgentActions: FC<AgentActionsProps> = ({
|
||||
activeTurnId,
|
||||
deleting,
|
||||
onDelete,
|
||||
onOpenTerminal,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const allowDelete = canDeleteAgent(agent)
|
||||
@@ -102,6 +105,12 @@ export const AgentActions: FC<AgentActionsProps> = ({
|
||||
<Copy className="mr-2 size-3.5" />
|
||||
Copy id
|
||||
</DropdownMenuItem>
|
||||
{onOpenTerminal && (
|
||||
<DropdownMenuItem onSelect={() => onOpenTerminal(agent)}>
|
||||
<Terminal className="mr-2 size-3.5" />
|
||||
Open terminal
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<ComingSoonItem
|
||||
icon={Pencil}
|
||||
label="Rename"
|
||||
|
||||
@@ -57,11 +57,11 @@ export const AgentSummaryChips: FC<AgentSummaryChipsProps> = ({
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="right" className="w-72 text-sm">
|
||||
<div className="font-medium">
|
||||
{adapterLabel(adapter)} CLI not available
|
||||
{adapterLabel(adapter)} runtime unavailable
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground text-xs">
|
||||
{adapterHealth.reason ??
|
||||
'Adapter binary missing on $PATH. Install it from the adapter docs to use this agent.'}
|
||||
'BrowserOS is still preparing this runtime. Retry after it is ready or use another adapter.'}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
@@ -47,5 +47,6 @@ export interface AgentRowData {
|
||||
|
||||
export interface AgentRowCallbacks {
|
||||
onDelete: (agent: AgentListItem) => void
|
||||
onOpenTerminal?: (agent: AgentListItem) => void
|
||||
onPinToggle: (agent: AgentListItem, next: boolean) => void
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { HarnessAdapterDescriptor } from './agent-harness-types'
|
||||
|
||||
export interface AdapterReadinessAlert {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function getAdapterReadinessAlert(
|
||||
adapter: HarnessAdapterDescriptor | undefined,
|
||||
): AdapterReadinessAlert | null {
|
||||
if (!adapter || adapter.health?.healthy !== false) return null
|
||||
return {
|
||||
title: `${adapter.name} runtime is not ready`,
|
||||
description:
|
||||
adapter.health.reason ??
|
||||
'BrowserOS is still preparing this runtime. Choose another adapter or retry after it is ready.',
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,7 @@ export function useAgentAdapters(enabled = true) {
|
||||
return data.adapters ?? []
|
||||
},
|
||||
enabled: Boolean(baseUrl) && !urlLoading && enabled,
|
||||
refetchInterval: enabled ? 5_000 : false,
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
} from '../services/terminal/terminal-protocol'
|
||||
import {
|
||||
createTerminalSession,
|
||||
TERMINAL_HOME_DIR,
|
||||
listTerminalTargets,
|
||||
resolveTerminalTarget,
|
||||
type TerminalSession,
|
||||
} from '../services/terminal/terminal-session'
|
||||
import type { Env } from '../types'
|
||||
@@ -15,12 +16,19 @@ import type { Env } from '../types'
|
||||
export const TERMINAL_WS_PATH = '/terminal/ws'
|
||||
|
||||
interface TerminalRouteDeps {
|
||||
browserosDir: string
|
||||
containerName: string
|
||||
listRunningContainers?: () => Promise<string[]>
|
||||
limaHome: string
|
||||
limactlPath: string | (() => string)
|
||||
vmName: string
|
||||
}
|
||||
|
||||
interface TerminalRequestTarget {
|
||||
target?: string | null
|
||||
agentId?: string | null
|
||||
}
|
||||
|
||||
function safeSend(ws: { send(data: string): void }, data: string): void {
|
||||
try {
|
||||
ws.send(data)
|
||||
@@ -39,7 +47,10 @@ function sendExit(ws: { send(data: string): void }, exitCode: number): void {
|
||||
safeSend(ws, serializeTerminalServerMessage({ type: 'exit', exitCode }))
|
||||
}
|
||||
|
||||
export function createTerminalSocketEvents(deps: TerminalRouteDeps) {
|
||||
export function createTerminalSocketEvents(
|
||||
deps: TerminalRouteDeps,
|
||||
requestTarget: TerminalRequestTarget = {},
|
||||
) {
|
||||
let session: TerminalSession | null = null
|
||||
|
||||
return {
|
||||
@@ -49,12 +60,17 @@ export function createTerminalSocketEvents(deps: TerminalRouteDeps) {
|
||||
typeof deps.limactlPath === 'function'
|
||||
? deps.limactlPath()
|
||||
: deps.limactlPath
|
||||
const target = resolveTerminalTarget({
|
||||
browserosDir: deps.browserosDir,
|
||||
target: requestTarget.target,
|
||||
agentId: requestTarget.agentId,
|
||||
openclawContainerName: deps.containerName,
|
||||
})
|
||||
session = createTerminalSession({
|
||||
containerName: deps.containerName,
|
||||
limaHome: deps.limaHome,
|
||||
limactlPath,
|
||||
target,
|
||||
vmName: deps.vmName,
|
||||
workingDir: TERMINAL_HOME_DIR,
|
||||
onOutput(data) {
|
||||
sendOutput(ws, data)
|
||||
},
|
||||
@@ -95,8 +111,28 @@ export function createTerminalSocketEvents(deps: TerminalRouteDeps) {
|
||||
}
|
||||
|
||||
export function createTerminalRoutes(deps: TerminalRouteDeps) {
|
||||
return new Hono<Env>().get(
|
||||
'/ws',
|
||||
upgradeWebSocket(() => createTerminalSocketEvents(deps)),
|
||||
)
|
||||
return new Hono<Env>()
|
||||
.get('/targets', async (c) => {
|
||||
let runningContainers: Set<string> | undefined
|
||||
if (deps.listRunningContainers) {
|
||||
runningContainers = new Set(await deps.listRunningContainers())
|
||||
}
|
||||
return c.json({
|
||||
targets: listTerminalTargets({
|
||||
browserosDir: deps.browserosDir,
|
||||
agentId: c.req.query('agentId'),
|
||||
runningContainers,
|
||||
openclawContainerName: deps.containerName,
|
||||
}),
|
||||
})
|
||||
})
|
||||
.get(
|
||||
'/ws',
|
||||
upgradeWebSocket((c) =>
|
||||
createTerminalSocketEvents(deps, {
|
||||
target: c.req.query('target'),
|
||||
agentId: c.req.query('agentId'),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ import { cors } from 'hono/cors'
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
||||
import { HttpAgentError } from '../agent/errors'
|
||||
import { INLINED_ENV } from '../env'
|
||||
import { getBrowserosDir } from '../lib/browseros-dir'
|
||||
import { KlavisClient } from '../lib/clients/klavis/klavis-client'
|
||||
import { initializeOAuth, shutdownOAuth } from '../lib/clients/oauth'
|
||||
import { ContainerCli } from '../lib/container/container-cli'
|
||||
import { getDb } from '../lib/db'
|
||||
import { logger } from '../lib/logger'
|
||||
import { Sentry } from '../lib/sentry'
|
||||
@@ -109,14 +111,24 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
.use('/*', requireTrustedAppOrigin())
|
||||
.route('/', createOpenClawRoutes())
|
||||
|
||||
const browserosDir = getBrowserosDir()
|
||||
const terminalLimaHome = getLimaHomeDir(browserosDir)
|
||||
const resolveTerminalLimactl = () => resolveBundledLimactl(resourcesDir)
|
||||
const terminalRoutes = new Hono<Env>()
|
||||
.use('/*', requireTrustedAppOrigin())
|
||||
.route(
|
||||
'/',
|
||||
createTerminalRoutes({
|
||||
browserosDir,
|
||||
containerName: OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
limaHome: getLimaHomeDir(),
|
||||
limactlPath: () => resolveBundledLimactl(resourcesDir),
|
||||
limaHome: terminalLimaHome,
|
||||
limactlPath: resolveTerminalLimactl,
|
||||
listRunningContainers: async () =>
|
||||
new ContainerCli({
|
||||
limactlPath: resolveTerminalLimactl(),
|
||||
limaHome: terminalLimaHome,
|
||||
vmName: VM_NAME,
|
||||
}).ps({ namesOnly: true }),
|
||||
vmName: VM_NAME,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { CLAUDE_CONTAINER_NAME } from '@browseros/shared/constants/claude'
|
||||
import { CODEX_CONTAINER_NAME } from '@browseros/shared/constants/codex'
|
||||
import {
|
||||
HERMES_CONTAINER_HARNESS_DIR,
|
||||
HERMES_CONTAINER_NAME,
|
||||
} from '@browseros/shared/constants/hermes'
|
||||
import {
|
||||
OPENCLAW_CONTAINER_HOME,
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
OPENCLAW_TERMINAL_SHELL,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { resolveVmAgentRuntimePaths } from '../../../lib/agents/acpx-runtime-context'
|
||||
import { getHermesAgentHomeHostDir } from '../../../lib/agents/hermes/hermes-paths'
|
||||
import { buildNerdctlCommand } from '../../../lib/container'
|
||||
import { logger } from '../../../lib/logger'
|
||||
|
||||
@@ -10,12 +21,23 @@ const DEFAULT_COLS = 80
|
||||
const DEFAULT_ROWS = 24
|
||||
const TERMINAL_NAME = 'xterm-256color'
|
||||
|
||||
interface TerminalSessionDeps {
|
||||
export type TerminalTargetId = 'openclaw' | 'claude' | 'codex' | 'hermes'
|
||||
|
||||
export interface TerminalTarget {
|
||||
id: TerminalTargetId
|
||||
label: string
|
||||
containerName: string
|
||||
workingDir: string
|
||||
shell: string
|
||||
env?: Record<string, string>
|
||||
running?: boolean
|
||||
}
|
||||
|
||||
interface TerminalSessionDeps {
|
||||
limaHome: string
|
||||
limactlPath: string
|
||||
target: TerminalTarget
|
||||
vmName: string
|
||||
workingDir: string
|
||||
onExit: (exitCode: number) => void
|
||||
onOutput: (data: string) => void
|
||||
}
|
||||
@@ -29,8 +51,7 @@ export interface TerminalSession {
|
||||
export function buildTerminalExecCommand(
|
||||
limactlPath: string,
|
||||
vmName: string,
|
||||
containerName: string,
|
||||
workingDir: string,
|
||||
target: TerminalTarget,
|
||||
): string[] {
|
||||
return [
|
||||
limactlPath,
|
||||
@@ -40,14 +61,130 @@ export function buildTerminalExecCommand(
|
||||
...buildNerdctlCommand([
|
||||
'exec',
|
||||
'-it',
|
||||
...envArgs(target.env),
|
||||
'-w',
|
||||
workingDir,
|
||||
containerName,
|
||||
OPENCLAW_TERMINAL_SHELL,
|
||||
target.workingDir,
|
||||
target.containerName,
|
||||
target.shell,
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
export function resolveTerminalTarget(input: {
|
||||
browserosDir: string
|
||||
target?: string | null
|
||||
agentId?: string | null
|
||||
materialize?: boolean
|
||||
openclawContainerName?: string
|
||||
}): TerminalTarget {
|
||||
const target = parseTargetId(input.target)
|
||||
switch (target) {
|
||||
case 'openclaw':
|
||||
return {
|
||||
id: 'openclaw',
|
||||
label: 'OpenClaw gateway',
|
||||
containerName:
|
||||
input.openclawContainerName ?? OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
workingDir: OPENCLAW_CONTAINER_HOME,
|
||||
shell: OPENCLAW_TERMINAL_SHELL,
|
||||
}
|
||||
case 'claude': {
|
||||
const agentId = requireAgentId(target, input.agentId)
|
||||
const paths = resolveVmAgentRuntimePaths({
|
||||
browserosDir: input.browserosDir,
|
||||
adapter: 'claude',
|
||||
agentId,
|
||||
})
|
||||
if (input.materialize !== false) {
|
||||
mkdirSync(paths.agentHome, { recursive: true })
|
||||
}
|
||||
return {
|
||||
id: 'claude',
|
||||
label: 'Claude Code runtime',
|
||||
containerName: CLAUDE_CONTAINER_NAME,
|
||||
workingDir: paths.agentHome,
|
||||
shell: '/bin/sh',
|
||||
env: {
|
||||
AGENT_HOME: paths.agentHome,
|
||||
HOME: paths.agentHome,
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'codex': {
|
||||
const agentId = requireAgentId(target, input.agentId)
|
||||
const paths = resolveVmAgentRuntimePaths({
|
||||
browserosDir: input.browserosDir,
|
||||
adapter: 'codex',
|
||||
agentId,
|
||||
})
|
||||
if (input.materialize !== false) {
|
||||
mkdirSync(paths.agentHome, { recursive: true })
|
||||
mkdirSync(paths.codexHome, { recursive: true })
|
||||
}
|
||||
return {
|
||||
id: 'codex',
|
||||
label: 'Codex runtime',
|
||||
containerName: CODEX_CONTAINER_NAME,
|
||||
workingDir: paths.agentHome,
|
||||
shell: '/bin/sh',
|
||||
env: {
|
||||
AGENT_HOME: paths.agentHome,
|
||||
CODEX_HOME: paths.codexHome,
|
||||
HOME: paths.agentHome,
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'hermes': {
|
||||
const agentId = requireAgentId(target, input.agentId)
|
||||
const hostHome = getHermesAgentHomeHostDir({
|
||||
browserosDir: input.browserosDir,
|
||||
agentId,
|
||||
})
|
||||
const containerHome = join(HERMES_CONTAINER_HARNESS_DIR, agentId, 'home')
|
||||
if (input.materialize !== false) {
|
||||
mkdirSync(hostHome, { recursive: true })
|
||||
}
|
||||
return {
|
||||
id: 'hermes',
|
||||
label: 'Hermes runtime',
|
||||
containerName: HERMES_CONTAINER_NAME,
|
||||
workingDir: containerHome,
|
||||
shell: '/bin/sh',
|
||||
env: {
|
||||
HERMES_HOME: containerHome,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function listTerminalTargets(input: {
|
||||
browserosDir: string
|
||||
agentId?: string | null
|
||||
runningContainers?: Set<string>
|
||||
openclawContainerName?: string
|
||||
}): TerminalTarget[] {
|
||||
const targets: TerminalTarget[] = []
|
||||
for (const target of ['openclaw', 'claude', 'codex', 'hermes'] as const) {
|
||||
try {
|
||||
const resolved = resolveTerminalTarget({
|
||||
browserosDir: input.browserosDir,
|
||||
target,
|
||||
agentId: input.agentId,
|
||||
materialize: false,
|
||||
openclawContainerName: input.openclawContainerName,
|
||||
})
|
||||
const running = input.runningContainers
|
||||
? input.runningContainers.has(resolved.containerName)
|
||||
: true
|
||||
if (running) targets.push({ ...resolved, running })
|
||||
} catch (error) {
|
||||
if (!isMissingAgentIdError(error)) throw error
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
export function buildTerminalEnv(limaHome: string): NodeJS.ProcessEnv {
|
||||
return { ...process.env, LIMA_HOME: limaHome, TERM: TERMINAL_NAME }
|
||||
}
|
||||
@@ -57,12 +194,7 @@ export function createTerminalSession(
|
||||
): TerminalSession {
|
||||
const decoder = new TextDecoder()
|
||||
const proc = Bun.spawn(
|
||||
buildTerminalExecCommand(
|
||||
deps.limactlPath,
|
||||
deps.vmName,
|
||||
deps.containerName,
|
||||
deps.workingDir,
|
||||
),
|
||||
buildTerminalExecCommand(deps.limactlPath, deps.vmName, deps.target),
|
||||
{
|
||||
cwd: '/',
|
||||
terminal: {
|
||||
@@ -84,7 +216,10 @@ export function createTerminalSession(
|
||||
deps.onExit(exitCode)
|
||||
})
|
||||
|
||||
logger.debug('Terminal session created', { workingDir: deps.workingDir })
|
||||
logger.debug('Terminal session created', {
|
||||
target: deps.target.id,
|
||||
workingDir: deps.target.workingDir,
|
||||
})
|
||||
|
||||
return {
|
||||
writeInput(data) {
|
||||
@@ -106,3 +241,46 @@ export function createTerminalSession(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseTargetId(value: string | null | undefined): TerminalTargetId {
|
||||
if (!value) return 'openclaw'
|
||||
if (
|
||||
value === 'openclaw' ||
|
||||
value === 'claude' ||
|
||||
value === 'codex' ||
|
||||
value === 'hermes'
|
||||
) {
|
||||
return value
|
||||
}
|
||||
throw new Error(`Unknown terminal target: ${value}`)
|
||||
}
|
||||
|
||||
function requireAgentId(
|
||||
target: TerminalTargetId,
|
||||
value: string | null | undefined,
|
||||
): string {
|
||||
const agentId = value?.trim()
|
||||
if (!agentId) {
|
||||
throw new Error(`agentId is required for ${target} terminal`)
|
||||
}
|
||||
if (
|
||||
agentId === '.' ||
|
||||
agentId === '..' ||
|
||||
agentId.includes('/') ||
|
||||
agentId.includes('\\')
|
||||
) {
|
||||
throw new Error('Invalid terminal agentId')
|
||||
}
|
||||
return agentId
|
||||
}
|
||||
|
||||
function envArgs(env: Record<string, string> | undefined): string[] {
|
||||
if (!env) return []
|
||||
return Object.entries(env)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.flatMap(([key, value]) => ['-e', `${key}=${value}`])
|
||||
}
|
||||
|
||||
function isMissingAgentIdError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.includes('agentId is required')
|
||||
}
|
||||
|
||||
@@ -33,13 +33,19 @@ export interface BrowserosManagedContext {
|
||||
/** Builds the common BrowserOS-managed home, skills, cwd, and prompt prefix for Claude/Codex. */
|
||||
export async function prepareBrowserosManagedContext(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
options: { paths?: AgentRuntimePaths; isDefaultWorkspace?: boolean } = {},
|
||||
): Promise<BrowserosManagedContext> {
|
||||
const paths = resolveAgentRuntimePaths({
|
||||
browserosDir: input.browserosDir,
|
||||
agentId: input.agent.id,
|
||||
cwd: input.cwdOverride,
|
||||
})
|
||||
await ensureUsableCwd(paths.effectiveCwd, !input.isSelectedCwd)
|
||||
const paths =
|
||||
options.paths ??
|
||||
resolveAgentRuntimePaths({
|
||||
browserosDir: input.browserosDir,
|
||||
agentId: input.agent.id,
|
||||
cwd: input.cwdOverride,
|
||||
})
|
||||
await ensureUsableCwd(
|
||||
paths.effectiveCwd,
|
||||
options.isDefaultWorkspace ?? !input.isSelectedCwd,
|
||||
)
|
||||
await ensureAgentHome(paths)
|
||||
const skillNames = await ensureRuntimeSkills(paths.runtimeSkillsDir)
|
||||
const promptPrefix = buildAcpxRuntimePromptPrefix({
|
||||
|
||||
@@ -6,15 +6,7 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { constants, type Stats } from 'node:fs'
|
||||
import {
|
||||
access,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { access, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { ensureDirectory } from '../ensure-directory'
|
||||
@@ -64,6 +56,31 @@ export function resolveAgentRuntimePaths(input: {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveVmAgentRuntimePaths(input: {
|
||||
browserosDir: string
|
||||
adapter: 'claude' | 'codex'
|
||||
agentId: string
|
||||
}): AgentRuntimePaths {
|
||||
const harnessDir = join(input.browserosDir, 'vm', input.adapter, 'harness')
|
||||
const defaultWorkspaceCwd = join(harnessDir, 'workspace')
|
||||
const runtimeRoot = join(harnessDir, input.agentId, 'runtime')
|
||||
return {
|
||||
browserosDir: input.browserosDir,
|
||||
harnessDir,
|
||||
agentHome: join(harnessDir, input.agentId, 'home'),
|
||||
defaultWorkspaceCwd,
|
||||
effectiveCwd: defaultWorkspaceCwd,
|
||||
runtimeStatePath: join(
|
||||
harnessDir,
|
||||
'runtime-state',
|
||||
`${input.agentId}.json`,
|
||||
),
|
||||
runtimeSkillsDir: join(harnessDir, 'runtime-skills'),
|
||||
runtimeRoot,
|
||||
codexHome: join(runtimeRoot, 'codex-home'),
|
||||
}
|
||||
}
|
||||
|
||||
/** Seeds the stable per-agent identity and memory home without overwriting edits. */
|
||||
export async function ensureAgentHome(paths: AgentRuntimePaths): Promise<void> {
|
||||
await ensureDirectory(join(paths.agentHome, 'memory'))
|
||||
@@ -94,9 +111,10 @@ export async function materializeCodexHome(input: {
|
||||
input.sourceCodexHome ??
|
||||
process.env.CODEX_HOME?.trim() ??
|
||||
join(homedir(), '.codex')
|
||||
await symlinkIfPresent(
|
||||
await copyIfPresent(
|
||||
join(source, 'auth.json'),
|
||||
join(input.paths.codexHome, 'auth.json'),
|
||||
{ overwrite: true },
|
||||
)
|
||||
for (const file of ['config.json', 'config.toml', 'instructions.md']) {
|
||||
await copyIfPresent(join(source, file), join(input.paths.codexHome, file))
|
||||
@@ -113,6 +131,23 @@ export async function materializeCodexHome(input: {
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepares the Claude home that Claude Code sees through HOME/.claude. */
|
||||
export async function materializeClaudeHome(input: {
|
||||
paths: AgentRuntimePaths
|
||||
sourceClaudeHome?: string
|
||||
}): Promise<void> {
|
||||
const source =
|
||||
input.sourceClaudeHome ??
|
||||
process.env.CLAUDE_CONFIG_DIR?.trim() ??
|
||||
join(homedir(), '.claude')
|
||||
const target = join(input.paths.agentHome, '.claude')
|
||||
for (const file of ['settings.json', 'CLAUDE.md']) {
|
||||
await copyIfPresent(join(source, file), join(target, file), {
|
||||
overwrite: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds stable BrowserOS-managed instructions for Claude/Codex ACP turns. */
|
||||
export function buildAcpxRuntimePromptPrefix(input: {
|
||||
agent: AgentDefinition
|
||||
@@ -203,19 +238,17 @@ async function writeFileIfMissing(
|
||||
}
|
||||
}
|
||||
|
||||
async function symlinkIfPresent(source: string, target: string): Promise<void> {
|
||||
if (!(await sourceFileExists(source))) return
|
||||
await ensureDirectory(dirname(target))
|
||||
try {
|
||||
await symlink(source, target)
|
||||
} catch (err) {
|
||||
if (!isAlreadyExistsError(err)) throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function copyIfPresent(source: string, target: string): Promise<void> {
|
||||
async function copyIfPresent(
|
||||
source: string,
|
||||
target: string,
|
||||
opts: { overwrite?: boolean } = {},
|
||||
): Promise<void> {
|
||||
if (!(await sourceFileExists(source))) return
|
||||
const content = await readFile(source, 'utf8')
|
||||
if (opts.overwrite) {
|
||||
await writeFileAtomic(target, content)
|
||||
return
|
||||
}
|
||||
await ensureDirectory(dirname(target))
|
||||
try {
|
||||
await writeFile(target, content, { encoding: 'utf8', flag: 'wx' })
|
||||
|
||||
@@ -23,6 +23,7 @@ import { logger } from '../logger'
|
||||
import { prepareAcpxAgentContext } from './acpx-agent-adapter'
|
||||
import {
|
||||
resolveAgentRuntimePaths,
|
||||
resolveVmAgentRuntimePaths,
|
||||
wrapCommandWithEnv,
|
||||
} from './acpx-runtime-context'
|
||||
import { loadLatestRuntimeState } from './acpx-runtime-state'
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
type OpenclawGatewayAccessor,
|
||||
resolveOpenclawAcpCommand,
|
||||
} from './openclaw/acp-command'
|
||||
import { getHermesRuntime } from './runtime'
|
||||
import { getClaudeRuntime, getCodexRuntime, getHermesRuntime } from './runtime'
|
||||
import type {
|
||||
AgentHistoryPage,
|
||||
AgentPromptInput,
|
||||
@@ -192,10 +193,17 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
private async loadLatestSessionRecord(
|
||||
agent: AgentPromptInput['agent'],
|
||||
): Promise<AcpSessionRecord | null> {
|
||||
const paths = resolveAgentRuntimePaths({
|
||||
browserosDir: this.browserosDir,
|
||||
agentId: agent.id,
|
||||
})
|
||||
const paths =
|
||||
agent.adapter === 'claude' || agent.adapter === 'codex'
|
||||
? resolveVmAgentRuntimePaths({
|
||||
browserosDir: this.browserosDir,
|
||||
adapter: agent.adapter,
|
||||
agentId: agent.id,
|
||||
})
|
||||
: resolveAgentRuntimePaths({
|
||||
browserosDir: this.browserosDir,
|
||||
agentId: agent.id,
|
||||
})
|
||||
const latest = await loadLatestRuntimeState(paths.runtimeStatePath)
|
||||
if (latest) {
|
||||
const latestRecord = await this.sessionStore.load(
|
||||
@@ -706,14 +714,17 @@ function createBrowserosAgentRegistry(input: {
|
||||
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') {
|
||||
if (lower === 'claude') {
|
||||
const runtime = getClaudeRuntime()
|
||||
if (runtime)
|
||||
return runtime.buildExecArgv(runtime.getAcpExecSpec(input.commandEnv))
|
||||
return wrapCommandWithEnv(registry.resolve(agentName), input.commandEnv)
|
||||
}
|
||||
|
||||
if (lower === 'codex') {
|
||||
const runtime = getCodexRuntime()
|
||||
if (runtime)
|
||||
return runtime.buildExecArgv(runtime.getAcpExecSpec(input.commandEnv))
|
||||
return wrapCommandWithEnv(registry.resolve(agentName), input.commandEnv)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
CLAUDE_CONTAINER_NAME,
|
||||
CLAUDE_IMAGE,
|
||||
} from '@browseros/shared/constants/claude'
|
||||
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 {
|
||||
materializeClaudeHome,
|
||||
resolveVmAgentRuntimePaths,
|
||||
} from '../acpx-runtime-context'
|
||||
import { ContainerAgentRuntime } from './container-agent-runtime'
|
||||
import { getAgentRuntimeRegistry } from './registry'
|
||||
import type { ExecSpec } from './types'
|
||||
|
||||
const CLAUDE_ACP_ARGV = ['claude-agent-acp'] as const
|
||||
const CLAUDE_CODE_START_COMMAND =
|
||||
'npm install -g @anthropic-ai/claude-code@latest @agentclientprotocol/claude-agent-acp@^0.31.0 && exec sleep infinity'
|
||||
|
||||
export interface ClaudeRuntimeConfig {
|
||||
browserosDir: string
|
||||
claudeHarnessHostDir: string
|
||||
}
|
||||
|
||||
export class ClaudeRuntime extends ContainerAgentRuntime {
|
||||
readonly descriptor: ContainerDescriptor & { kind: 'container' } = {
|
||||
adapterId: 'claude',
|
||||
displayName: 'Claude Code',
|
||||
kind: 'container',
|
||||
defaultImage: CLAUDE_IMAGE,
|
||||
containerName: CLAUDE_CONTAINER_NAME,
|
||||
platforms: ['darwin'],
|
||||
readinessProbe: { timeoutMs: 120_000, intervalMs: 500 },
|
||||
}
|
||||
|
||||
private readonly claudeConfig: ClaudeRuntimeConfig
|
||||
|
||||
constructor(deps: ManagedContainerDeps, config: ClaudeRuntimeConfig) {
|
||||
super(deps)
|
||||
this.claudeConfig = config
|
||||
}
|
||||
|
||||
protected mountRoots(): readonly MountRoot[] {
|
||||
return [
|
||||
{
|
||||
hostPath: this.claudeConfig.claudeHarnessHostDir,
|
||||
containerPath: this.claudeConfig.claudeHarnessHostDir,
|
||||
kind: 'shared',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
protected async buildContainerSpec(): Promise<ContainerSpec> {
|
||||
const guestHarnessDir = `${GUEST_VM_STATE}/claude/harness`
|
||||
const gateway = await this.deps.vm.getDefaultGateway()
|
||||
return {
|
||||
name: CLAUDE_CONTAINER_NAME,
|
||||
image: CLAUDE_IMAGE,
|
||||
restart: 'unless-stopped',
|
||||
addHosts: [`host.containers.internal:${gateway}`],
|
||||
mounts: [
|
||||
{
|
||||
source: guestHarnessDir,
|
||||
target: this.claudeConfig.claudeHarnessHostDir,
|
||||
},
|
||||
],
|
||||
entrypoint: '/bin/sh',
|
||||
command: ['-c', CLAUDE_CODE_START_COMMAND],
|
||||
}
|
||||
}
|
||||
|
||||
protected async readinessProbe(): Promise<boolean> {
|
||||
try {
|
||||
const exitCode = await this.deps.cli.exec(this.descriptor.containerName, [
|
||||
'sh',
|
||||
'-lc',
|
||||
'command -v claude >/dev/null && command -v claude-agent-acp >/dev/null',
|
||||
])
|
||||
return exitCode === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
getPerAgentHomeDir(agentId: string): string {
|
||||
return resolveVmAgentRuntimePaths({
|
||||
browserosDir: this.claudeConfig.browserosDir,
|
||||
adapter: 'claude',
|
||||
agentId,
|
||||
}).agentHome
|
||||
}
|
||||
|
||||
getAcpExecSpec(commandEnv: Record<string, string>): ExecSpec {
|
||||
return {
|
||||
argv: [...CLAUDE_ACP_ARGV],
|
||||
env: { ...commandEnv },
|
||||
}
|
||||
}
|
||||
|
||||
prepareTurnContext(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext> {
|
||||
return prepareClaudeCodeContext(input)
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepares Claude Code with a VM-visible BrowserOS agent home. */
|
||||
export async function prepareClaudeCodeContext(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext> {
|
||||
const paths = resolveVmAgentRuntimePaths({
|
||||
browserosDir: input.browserosDir,
|
||||
adapter: 'claude',
|
||||
agentId: input.agent.id,
|
||||
})
|
||||
const common = await prepareBrowserosManagedContext(input, {
|
||||
paths,
|
||||
isDefaultWorkspace: true,
|
||||
})
|
||||
await materializeClaudeHome({ paths: common.paths })
|
||||
return finishBrowserosManagedContext({
|
||||
...common,
|
||||
commandEnv: {
|
||||
AGENT_HOME: common.paths.agentHome,
|
||||
HOME: common.paths.agentHome,
|
||||
},
|
||||
browserosMcpHost: 'host.containers.internal',
|
||||
})
|
||||
}
|
||||
|
||||
export interface ConfigureClaudeRuntimeOptions {
|
||||
resourcesDir?: string
|
||||
browserosDir?: string
|
||||
}
|
||||
|
||||
export function configureClaudeRuntime(
|
||||
options: ConfigureClaudeRuntimeOptions = {},
|
||||
): ClaudeRuntime | null {
|
||||
if (process.platform !== 'darwin') {
|
||||
logger.warn('Claude 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 claudeHostStateDir = join(browserosDir, 'vm', 'claude')
|
||||
const claudeHarnessHostDir = join(claudeHostStateDir, 'harness')
|
||||
|
||||
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 ClaudeRuntime(
|
||||
{
|
||||
cli,
|
||||
loader,
|
||||
vm,
|
||||
limactlPath,
|
||||
limaHome,
|
||||
vmName: VM_NAME,
|
||||
lockDir: join(claudeHostStateDir, '.locks'),
|
||||
},
|
||||
{ browserosDir, claudeHarnessHostDir },
|
||||
)
|
||||
getAgentRuntimeRegistry().register(runtime)
|
||||
logger.debug('ClaudeRuntime registered', { image: CLAUDE_IMAGE })
|
||||
return runtime
|
||||
}
|
||||
|
||||
export function getClaudeRuntime(): ClaudeRuntime | null {
|
||||
const r = getAgentRuntimeRegistry().get('claude')
|
||||
return r instanceof ClaudeRuntime ? r : null
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* @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,220 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
CODEX_CONTAINER_NAME,
|
||||
CODEX_IMAGE,
|
||||
} from '@browseros/shared/constants/codex'
|
||||
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 {
|
||||
materializeCodexHome,
|
||||
resolveVmAgentRuntimePaths,
|
||||
} from '../acpx-runtime-context'
|
||||
import { ContainerAgentRuntime } from './container-agent-runtime'
|
||||
import { getAgentRuntimeRegistry } from './registry'
|
||||
import type { ExecSpec } from './types'
|
||||
|
||||
const CODEX_ACP_ARGV = ['codex-acp'] as const
|
||||
const CODEX_START_COMMAND =
|
||||
'apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* && npm install -g @openai/codex@latest @zed-industries/codex-acp@^0.12.0 && exec sleep infinity'
|
||||
|
||||
export interface CodexRuntimeConfig {
|
||||
browserosDir: string
|
||||
codexHarnessHostDir: string
|
||||
}
|
||||
|
||||
export class CodexRuntime extends ContainerAgentRuntime {
|
||||
readonly descriptor: ContainerDescriptor & { kind: 'container' } = {
|
||||
adapterId: 'codex',
|
||||
displayName: 'Codex',
|
||||
kind: 'container',
|
||||
defaultImage: CODEX_IMAGE,
|
||||
containerName: CODEX_CONTAINER_NAME,
|
||||
platforms: ['darwin'],
|
||||
readinessProbe: { timeoutMs: 120_000, intervalMs: 500 },
|
||||
}
|
||||
|
||||
private readonly codexConfig: CodexRuntimeConfig
|
||||
|
||||
constructor(deps: ManagedContainerDeps, config: CodexRuntimeConfig) {
|
||||
super(deps)
|
||||
this.codexConfig = config
|
||||
}
|
||||
|
||||
protected mountRoots(): readonly MountRoot[] {
|
||||
return [
|
||||
{
|
||||
hostPath: this.codexConfig.codexHarnessHostDir,
|
||||
containerPath: this.codexConfig.codexHarnessHostDir,
|
||||
kind: 'shared',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
protected async buildContainerSpec(): Promise<ContainerSpec> {
|
||||
const guestHarnessDir = `${GUEST_VM_STATE}/codex/harness`
|
||||
const gateway = await this.deps.vm.getDefaultGateway()
|
||||
return {
|
||||
name: CODEX_CONTAINER_NAME,
|
||||
image: CODEX_IMAGE,
|
||||
restart: 'unless-stopped',
|
||||
addHosts: [`host.containers.internal:${gateway}`],
|
||||
mounts: [
|
||||
{
|
||||
source: guestHarnessDir,
|
||||
target: this.codexConfig.codexHarnessHostDir,
|
||||
},
|
||||
],
|
||||
entrypoint: '/bin/sh',
|
||||
command: ['-c', CODEX_START_COMMAND],
|
||||
}
|
||||
}
|
||||
|
||||
protected async readinessProbe(): Promise<boolean> {
|
||||
try {
|
||||
const exitCode = await this.deps.cli.exec(this.descriptor.containerName, [
|
||||
'sh',
|
||||
'-lc',
|
||||
'command -v codex >/dev/null && command -v codex-acp >/dev/null && codex-acp --help >/dev/null',
|
||||
])
|
||||
return exitCode === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
getPerAgentHomeDir(agentId: string): string {
|
||||
return resolveVmAgentRuntimePaths({
|
||||
browserosDir: this.codexConfig.browserosDir,
|
||||
adapter: 'codex',
|
||||
agentId,
|
||||
}).agentHome
|
||||
}
|
||||
|
||||
getAcpExecSpec(commandEnv: Record<string, string>): ExecSpec {
|
||||
return {
|
||||
argv: [...CODEX_ACP_ARGV],
|
||||
env: { ...commandEnv },
|
||||
}
|
||||
}
|
||||
|
||||
prepareTurnContext(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext> {
|
||||
return prepareCodexContext(input)
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepares Codex with VM-visible AGENT_HOME and CODEX_HOME. */
|
||||
export async function prepareCodexContext(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext> {
|
||||
const paths = resolveVmAgentRuntimePaths({
|
||||
browserosDir: input.browserosDir,
|
||||
adapter: 'codex',
|
||||
agentId: input.agent.id,
|
||||
})
|
||||
const common = await prepareBrowserosManagedContext(input, {
|
||||
paths,
|
||||
isDefaultWorkspace: true,
|
||||
})
|
||||
await materializeCodexHome({
|
||||
paths: common.paths,
|
||||
skillNames: common.skillNames,
|
||||
})
|
||||
return finishBrowserosManagedContext({
|
||||
...common,
|
||||
commandEnv: {
|
||||
AGENT_HOME: common.paths.agentHome,
|
||||
CODEX_HOME: common.paths.codexHome,
|
||||
HOME: common.paths.agentHome,
|
||||
},
|
||||
browserosMcpHost: 'host.containers.internal',
|
||||
})
|
||||
}
|
||||
|
||||
export interface ConfigureCodexRuntimeOptions {
|
||||
resourcesDir?: string
|
||||
browserosDir?: string
|
||||
}
|
||||
|
||||
export function configureCodexRuntime(
|
||||
options: ConfigureCodexRuntimeOptions = {},
|
||||
): CodexRuntime | null {
|
||||
if (process.platform !== 'darwin') {
|
||||
logger.warn('Codex 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 codexHostStateDir = join(browserosDir, 'vm', 'codex')
|
||||
const codexHarnessHostDir = join(codexHostStateDir, 'harness')
|
||||
|
||||
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 CodexRuntime(
|
||||
{
|
||||
cli,
|
||||
loader,
|
||||
vm,
|
||||
limactlPath,
|
||||
limaHome,
|
||||
vmName: VM_NAME,
|
||||
lockDir: join(codexHostStateDir, '.locks'),
|
||||
},
|
||||
{ browserosDir, codexHarnessHostDir },
|
||||
)
|
||||
getAgentRuntimeRegistry().register(runtime)
|
||||
logger.debug('CodexRuntime registered', { image: CODEX_IMAGE })
|
||||
return runtime
|
||||
}
|
||||
|
||||
export function getCodexRuntime(): CodexRuntime | null {
|
||||
const r = getAgentRuntimeRegistry().get('codex')
|
||||
return r instanceof CodexRuntime ? r : null
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
ContainerStatusSnapshot,
|
||||
} from '../../container/managed'
|
||||
import { ManagedContainer } from '../../container/managed'
|
||||
import { logger } from '../../logger'
|
||||
import type { AgentRuntime } from './agent-runtime'
|
||||
import { ActionNotSupportedError } from './errors'
|
||||
import type {
|
||||
@@ -119,3 +120,42 @@ function actionToCapability(action: RuntimeAction): RuntimeCapability {
|
||||
// sub-capabilities) without re-flowing the dispatcher.
|
||||
return action.type as RuntimeCapability
|
||||
}
|
||||
|
||||
export function startContainerRuntimeBestEffort(
|
||||
configure: () => ContainerAgentRuntime | null,
|
||||
): ContainerAgentRuntime | null {
|
||||
let runtime: ContainerAgentRuntime | null
|
||||
try {
|
||||
runtime = configure()
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'Container runtime configuration failed, continuing without it',
|
||||
{
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!runtime) return null
|
||||
|
||||
scheduleContainerRuntimeAction(runtime, { type: 'install' }, 'prewarm failed')
|
||||
scheduleContainerRuntimeAction(
|
||||
runtime,
|
||||
{ type: 'start' },
|
||||
'container start failed',
|
||||
)
|
||||
return runtime
|
||||
}
|
||||
|
||||
function scheduleContainerRuntimeAction(
|
||||
runtime: ContainerAgentRuntime,
|
||||
action: RuntimeAction,
|
||||
failureMessage: string,
|
||||
): void {
|
||||
void runtime.executeAction(action).catch((err) =>
|
||||
logger.warn(`${runtime.descriptor.displayName} ${failureMessage}`, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -254,16 +254,6 @@ export interface ConfigureHermesRuntimeOptions {
|
||||
browserosDir?: string
|
||||
}
|
||||
|
||||
export type HermesRuntimeStartupPhase = 'configure' | 'install' | 'start'
|
||||
|
||||
export interface StartHermesRuntimeBestEffortOptions
|
||||
extends ConfigureHermesRuntimeOptions {
|
||||
configureRuntime?: (
|
||||
options: ConfigureHermesRuntimeOptions,
|
||||
) => HermesContainerRuntime | null
|
||||
onError?: (phase: HermesRuntimeStartupPhase, error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `HermesContainerRuntime` with production deps (bundled
|
||||
* limactl, BrowserOS state dirs, Lima VM runtime) and register it in
|
||||
@@ -322,55 +312,8 @@ export function configureHermesRuntime(
|
||||
return runtime
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup wiring for the Hermes adapter. Kept beside the adapter runtime so
|
||||
* the server entry point does not need to know Hermes' install/start sequence.
|
||||
*/
|
||||
export function startHermesRuntimeBestEffort(
|
||||
options: StartHermesRuntimeBestEffortOptions = {},
|
||||
): HermesContainerRuntime | null {
|
||||
const {
|
||||
configureRuntime = configureHermesRuntime,
|
||||
onError = logHermesStartupError,
|
||||
...configureOptions
|
||||
} = options
|
||||
|
||||
let runtime: HermesContainerRuntime | null
|
||||
try {
|
||||
runtime = configureRuntime(configureOptions)
|
||||
} catch (err) {
|
||||
onError('configure', err)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!runtime) return null
|
||||
|
||||
void runtime
|
||||
.executeAction({ type: 'install' })
|
||||
.catch((err) => onError('install', err))
|
||||
void runtime
|
||||
.executeAction({ type: 'start' })
|
||||
.catch((err) => onError('start', err))
|
||||
return runtime
|
||||
}
|
||||
|
||||
/** Convenience getter — returns the registered runtime or null. */
|
||||
export function getHermesRuntime(): HermesContainerRuntime | null {
|
||||
const r = getAgentRuntimeRegistry().get('hermes')
|
||||
return r instanceof HermesContainerRuntime ? r : null
|
||||
}
|
||||
|
||||
function logHermesStartupError(
|
||||
phase: HermesRuntimeStartupPhase,
|
||||
error: unknown,
|
||||
): void {
|
||||
const message =
|
||||
phase === 'configure'
|
||||
? 'Hermes container configuration failed, continuing without it'
|
||||
: phase === 'install'
|
||||
? 'Hermes prewarm failed'
|
||||
: 'Hermes container start failed'
|
||||
logger.warn(message, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export {
|
||||
configureClaudeRuntime,
|
||||
getClaudeRuntime,
|
||||
prepareClaudeCodeContext,
|
||||
} from './claude-host-process-runtime'
|
||||
} from './claude-container-runtime'
|
||||
export {
|
||||
CodexRuntime,
|
||||
type CodexRuntimeConfig,
|
||||
@@ -20,8 +20,11 @@ export {
|
||||
configureCodexRuntime,
|
||||
getCodexRuntime,
|
||||
prepareCodexContext,
|
||||
} from './codex-host-process-runtime'
|
||||
export { ContainerAgentRuntime } from './container-agent-runtime'
|
||||
} from './codex-container-runtime'
|
||||
export {
|
||||
ContainerAgentRuntime,
|
||||
startContainerRuntimeBestEffort,
|
||||
} from './container-agent-runtime'
|
||||
export { ActionNotSupportedError, RuntimeNotReadyError } from './errors'
|
||||
export {
|
||||
type ConfigureHermesRuntimeOptions,
|
||||
@@ -30,8 +33,6 @@ export {
|
||||
HermesContainerRuntime,
|
||||
type HermesContainerRuntimeConfig,
|
||||
prepareHermesContext,
|
||||
type StartHermesRuntimeBestEffortOptions,
|
||||
startHermesRuntimeBestEffort,
|
||||
} from './hermes-container-runtime'
|
||||
export {
|
||||
HostProcessAgentRuntime,
|
||||
|
||||
@@ -206,8 +206,13 @@ export abstract class ManagedContainer {
|
||||
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()
|
||||
// or HTTP /readyz call. The container can report running
|
||||
// before its entrypoint has completed setup, so retry within
|
||||
// the same readiness budget before marking it errored.
|
||||
const probeOk = await this.waitForReadinessProbe({
|
||||
timeoutMs: probeOpts?.timeoutMs ?? 30_000,
|
||||
intervalMs: probeOpts?.intervalMs ?? 500,
|
||||
})
|
||||
if (!probeOk) {
|
||||
this.setState(
|
||||
'errored',
|
||||
@@ -228,6 +233,18 @@ export abstract class ManagedContainer {
|
||||
})
|
||||
}
|
||||
|
||||
private async waitForReadinessProbe(opts: {
|
||||
timeoutMs: number
|
||||
intervalMs: number
|
||||
}): Promise<boolean> {
|
||||
const deadline = Date.now() + opts.timeoutMs
|
||||
while (true) {
|
||||
if (await this.readinessProbe()) return true
|
||||
if (Date.now() >= deadline) return false
|
||||
await Bun.sleep(Math.min(opts.intervalMs, deadline - Date.now()))
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop and remove the container. Image and per-agent data preserved. */
|
||||
async stop(): Promise<void> {
|
||||
return this.withLifecycleLock('stop', async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { VM_TELEMETRY_EVENTS } from './telemetry'
|
||||
|
||||
export type LogFn = (msg: string) => void
|
||||
const ROOTLESS_CONTAINERD_MARKER = 'runtime:containerd-rootless'
|
||||
const ensureReadyPromises = new Map<string, Promise<void>>()
|
||||
|
||||
export interface VmRuntimeDeps {
|
||||
limactlPath: string
|
||||
@@ -43,6 +44,30 @@ export class VmRuntime {
|
||||
}
|
||||
|
||||
async ensureReady(onLog?: LogFn): Promise<void> {
|
||||
const lockKey = this.ensureReadyLockKey()
|
||||
const existing = ensureReadyPromises.get(lockKey)
|
||||
if (existing) {
|
||||
onLog?.('Waiting for BrowserOS VM...')
|
||||
await existing
|
||||
return
|
||||
}
|
||||
|
||||
const promise = this.ensureReadyOnce(onLog)
|
||||
ensureReadyPromises.set(lockKey, promise)
|
||||
try {
|
||||
await promise
|
||||
} finally {
|
||||
if (ensureReadyPromises.get(lockKey) === promise) {
|
||||
ensureReadyPromises.delete(lockKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ensureReadyLockKey(): string {
|
||||
return `${this.deps.limaHome}:${VM_NAME}`
|
||||
}
|
||||
|
||||
private async ensureReadyOnce(onLog?: LogFn): Promise<void> {
|
||||
const started = Date.now()
|
||||
logger.info(VM_TELEMETRY_EVENTS.ensureReadyStart, {
|
||||
limaHome: this.deps.limaHome,
|
||||
|
||||
@@ -24,8 +24,11 @@ import { INLINED_ENV } from './env'
|
||||
import {
|
||||
configureClaudeRuntime,
|
||||
configureCodexRuntime,
|
||||
configureHermesRuntime,
|
||||
getClaudeRuntime,
|
||||
getCodexRuntime,
|
||||
getHermesRuntime,
|
||||
startHermesRuntimeBestEffort,
|
||||
startContainerRuntimeBestEffort,
|
||||
} from './lib/agents/runtime'
|
||||
import {
|
||||
cleanOldSessions,
|
||||
@@ -66,8 +69,6 @@ export class Application {
|
||||
|
||||
const resourcesDir = path.resolve(this.config.resourcesDir)
|
||||
configureVmRuntime({ resourcesDir })
|
||||
configureClaudeRuntime()
|
||||
configureCodexRuntime()
|
||||
await this.initCoreServices()
|
||||
|
||||
if (!this.config.cdpPort) {
|
||||
@@ -158,7 +159,15 @@ export class Application {
|
||||
})
|
||||
}
|
||||
|
||||
startHermesRuntimeBestEffort({ resourcesDir })
|
||||
startContainerRuntimeBestEffort(() =>
|
||||
configureClaudeRuntime({ resourcesDir }),
|
||||
)
|
||||
startContainerRuntimeBestEffort(() =>
|
||||
configureCodexRuntime({ resourcesDir }),
|
||||
)
|
||||
startContainerRuntimeBestEffort(() =>
|
||||
configureHermesRuntime({ resourcesDir }),
|
||||
)
|
||||
|
||||
metrics.log('http_server.started', { version: VERSION })
|
||||
}
|
||||
@@ -172,6 +181,12 @@ export class Application {
|
||||
getHermesRuntime()
|
||||
?.executeAction({ type: 'stop' })
|
||||
.catch(() => {})
|
||||
getClaudeRuntime()
|
||||
?.executeAction({ type: 'stop' })
|
||||
.catch(() => {})
|
||||
getCodexRuntime()
|
||||
?.executeAction({ type: 'stop' })
|
||||
.catch(() => {})
|
||||
removeServerConfigSync()
|
||||
|
||||
// Immediate exit without graceful shutdown. Chromium may kill us on update/restart,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { CLAUDE_CONTAINER_NAME } from '@browseros/shared/constants/claude'
|
||||
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
|
||||
import {
|
||||
parseTerminalClientMessage,
|
||||
@@ -12,7 +13,8 @@ import {
|
||||
import {
|
||||
buildTerminalEnv,
|
||||
buildTerminalExecCommand,
|
||||
TERMINAL_HOME_DIR,
|
||||
listTerminalTargets,
|
||||
resolveTerminalTarget,
|
||||
} from '../../../src/api/services/terminal/terminal-session'
|
||||
|
||||
describe('terminal protocol', () => {
|
||||
@@ -56,8 +58,10 @@ describe('terminal protocol', () => {
|
||||
buildTerminalExecCommand(
|
||||
'limactl',
|
||||
'browseros-vm',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
TERMINAL_HOME_DIR,
|
||||
resolveTerminalTarget({
|
||||
browserosDir: '/tmp/browseros',
|
||||
target: 'openclaw',
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
'limactl',
|
||||
@@ -74,6 +78,61 @@ describe('terminal protocol', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('builds a Claude terminal command with the selected agent home', () => {
|
||||
const target = resolveTerminalTarget({
|
||||
browserosDir: '/tmp/browseros',
|
||||
target: 'claude',
|
||||
agentId: 'agent-1',
|
||||
})
|
||||
|
||||
const agentHome = '/tmp/browseros/vm/claude/harness/agent-1/home'
|
||||
expect(target).toMatchObject({
|
||||
id: 'claude',
|
||||
containerName: CLAUDE_CONTAINER_NAME,
|
||||
workingDir: agentHome,
|
||||
env: {
|
||||
AGENT_HOME: agentHome,
|
||||
HOME: agentHome,
|
||||
},
|
||||
})
|
||||
expect(buildTerminalExecCommand('limactl', 'browseros-vm', target)).toEqual(
|
||||
[
|
||||
'limactl',
|
||||
'shell',
|
||||
'browseros-vm',
|
||||
'--',
|
||||
'nerdctl',
|
||||
'exec',
|
||||
'-it',
|
||||
'-e',
|
||||
`AGENT_HOME=${agentHome}`,
|
||||
'-e',
|
||||
`HOME=${agentHome}`,
|
||||
'-w',
|
||||
agentHome,
|
||||
CLAUDE_CONTAINER_NAME,
|
||||
'/bin/sh',
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
it('lists only running managed terminal targets for an agent', () => {
|
||||
expect(
|
||||
listTerminalTargets({
|
||||
browserosDir: '/tmp/browseros',
|
||||
agentId: 'agent-1',
|
||||
runningContainers: new Set([CLAUDE_CONTAINER_NAME]),
|
||||
}),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'claude',
|
||||
label: 'Claude Code runtime',
|
||||
containerName: CLAUDE_CONTAINER_NAME,
|
||||
running: true,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('sets LIMA_HOME for terminal limactl sessions', () => {
|
||||
expect(buildTerminalEnv('/tmp/browseros-lima')).toEqual(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -4,10 +4,21 @@
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { CLAUDE_CONTAINER_NAME } from '@browseros/shared/constants/claude'
|
||||
|
||||
describe('createTerminalSocketEvents', () => {
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
return Promise.all(
|
||||
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
).then(() => {
|
||||
tempDirs.length = 0
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves limactl only when a terminal socket opens', async () => {
|
||||
@@ -34,6 +45,7 @@ describe('createTerminalSocketEvents', () => {
|
||||
const resolveLimactlPath = mock(() => '/tmp/fake-limactl')
|
||||
|
||||
const events = createTerminalSocketEvents({
|
||||
browserosDir: '/tmp/browseros',
|
||||
containerName: 'gateway',
|
||||
limaHome: '/tmp/lima',
|
||||
limactlPath: resolveLimactlPath,
|
||||
@@ -47,11 +59,69 @@ describe('createTerminalSocketEvents', () => {
|
||||
expect(resolveLimactlPath).toHaveBeenCalledTimes(1)
|
||||
expect(createTerminalSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: expect.objectContaining({ containerName: 'gateway' }),
|
||||
limaHome: '/tmp/lima',
|
||||
limactlPath: '/tmp/fake-limactl',
|
||||
vmName: 'browseros-vm',
|
||||
}),
|
||||
)
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens a Claude terminal target for the selected agent', async () => {
|
||||
const browserosDir = await mkdtemp(join(tmpdir(), 'browseros-terminal-'))
|
||||
tempDirs.push(browserosDir)
|
||||
const close = mock(() => {})
|
||||
const send = mock(() => {})
|
||||
const session = {
|
||||
close: mock(() => {}),
|
||||
resize: mock(() => {}),
|
||||
writeInput: mock(() => {}),
|
||||
}
|
||||
const createTerminalSession = mock(() => session)
|
||||
const actualTerminalSession = await import(
|
||||
'../../../src/api/services/terminal/terminal-session'
|
||||
)
|
||||
|
||||
mock.module('../../../src/api/services/terminal/terminal-session', () => ({
|
||||
...actualTerminalSession,
|
||||
createTerminalSession,
|
||||
}))
|
||||
|
||||
const { createTerminalSocketEvents } = await import(
|
||||
'../../../src/api/routes/terminal'
|
||||
)
|
||||
const events = createTerminalSocketEvents(
|
||||
{
|
||||
browserosDir,
|
||||
containerName: 'gateway',
|
||||
limaHome: '/tmp/lima',
|
||||
limactlPath: '/tmp/fake-limactl',
|
||||
vmName: 'browseros-vm',
|
||||
workingDir: actualTerminalSession.TERMINAL_HOME_DIR,
|
||||
},
|
||||
{ target: 'claude', agentId: 'agent-1' },
|
||||
)
|
||||
|
||||
events.onOpen(new Event('open'), { send, close })
|
||||
|
||||
const agentHome = join(
|
||||
browserosDir,
|
||||
'vm',
|
||||
'claude',
|
||||
'harness',
|
||||
'agent-1',
|
||||
'home',
|
||||
)
|
||||
expect(createTerminalSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: expect.objectContaining({
|
||||
containerName: CLAUDE_CONTAINER_NAME,
|
||||
workingDir: agentHome,
|
||||
env: {
|
||||
AGENT_HOME: agentHome,
|
||||
HOME: agentHome,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
@@ -76,6 +146,7 @@ describe('createTerminalSocketEvents', () => {
|
||||
'../../../src/api/routes/terminal'
|
||||
)
|
||||
const events = createTerminalSocketEvents({
|
||||
browserosDir: '/tmp/browseros',
|
||||
containerName: 'gateway',
|
||||
limaHome: '/tmp/lima',
|
||||
limactlPath: () => {
|
||||
@@ -93,3 +164,35 @@ describe('createTerminalSocketEvents', () => {
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTerminalRoutes', () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
it('returns running managed terminal targets', async () => {
|
||||
const { createTerminalRoutes } = await import(
|
||||
'../../../src/api/routes/terminal'
|
||||
)
|
||||
const route = createTerminalRoutes({
|
||||
browserosDir: '/tmp/browseros',
|
||||
containerName: 'gateway',
|
||||
limaHome: '/tmp/lima',
|
||||
limactlPath: '/tmp/fake-limactl',
|
||||
vmName: 'browseros-vm',
|
||||
listRunningContainers: async () => [CLAUDE_CONTAINER_NAME],
|
||||
})
|
||||
|
||||
const res = await route.request('/targets?agentId=agent-1')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
targets: [
|
||||
expect.objectContaining({
|
||||
id: 'claude',
|
||||
containerName: CLAUDE_CONTAINER_NAME,
|
||||
running: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
chmod,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
buildAcpxRuntimePromptPrefix,
|
||||
ensureAgentHome,
|
||||
ensureRuntimeSkills,
|
||||
materializeClaudeHome,
|
||||
materializeCodexHome,
|
||||
resolveAgentRuntimePaths,
|
||||
wrapCommandWithEnv,
|
||||
@@ -161,7 +162,7 @@ describe('acpx runtime context helpers', () => {
|
||||
expect(await readFile(skillPath, 'utf8')).toContain('BrowserOS MCP')
|
||||
})
|
||||
|
||||
it('materializes Codex home with auth symlink and all runtime skills', async () => {
|
||||
it('materializes Codex home with copied auth and all runtime skills', async () => {
|
||||
const browserosDir = await mkdtemp(join(tmpdir(), 'browseros-context-'))
|
||||
const sourceCodexHome = await mkdtemp(
|
||||
join(tmpdir(), 'browseros-codex-src-'),
|
||||
@@ -174,8 +175,11 @@ describe('acpx runtime context helpers', () => {
|
||||
|
||||
await materializeCodexHome({ paths, skillNames: skills, sourceCodexHome })
|
||||
|
||||
const auth = await lstat(join(paths.codexHome, 'auth.json'))
|
||||
expect(auth.isSymbolicLink()).toBe(true)
|
||||
const auth = await stat(join(paths.codexHome, 'auth.json'))
|
||||
expect(auth.isFile()).toBe(true)
|
||||
expect(await readFile(join(paths.codexHome, 'auth.json'), 'utf8')).toBe(
|
||||
'{"ok":true}\n',
|
||||
)
|
||||
expect(await readFile(join(paths.codexHome, 'config.toml'), 'utf8')).toBe(
|
||||
'model = "test"\n',
|
||||
)
|
||||
@@ -187,6 +191,32 @@ describe('acpx runtime context helpers', () => {
|
||||
).toContain('BrowserOS MCP')
|
||||
})
|
||||
|
||||
it('materializes Claude home with portable settings under HOME/.claude', async () => {
|
||||
const browserosDir = await mkdtemp(join(tmpdir(), 'browseros-context-'))
|
||||
const sourceClaudeHome = await mkdtemp(
|
||||
join(tmpdir(), 'browseros-claude-src-'),
|
||||
)
|
||||
tempDirs.push(browserosDir, sourceClaudeHome)
|
||||
await writeFile(
|
||||
join(sourceClaudeHome, 'settings.json'),
|
||||
'{"apiKeyHelper":"echo test"}\n',
|
||||
)
|
||||
await writeFile(join(sourceClaudeHome, 'CLAUDE.md'), '# User memory\n')
|
||||
const paths = resolveAgentRuntimePaths({ browserosDir, agentId: 'agent-1' })
|
||||
|
||||
await materializeClaudeHome({
|
||||
paths,
|
||||
sourceClaudeHome,
|
||||
})
|
||||
|
||||
expect(
|
||||
await readFile(join(paths.agentHome, '.claude', 'settings.json'), 'utf8'),
|
||||
).toBe('{"apiKeyHelper":"echo test"}\n')
|
||||
expect(
|
||||
await readFile(join(paths.agentHome, '.claude', 'CLAUDE.md'), 'utf8'),
|
||||
).toBe('# User memory\n')
|
||||
})
|
||||
|
||||
it('rejects non-file Codex auth sources instead of silently skipping auth', async () => {
|
||||
const browserosDir = await mkdtemp(join(tmpdir(), 'browseros-context-'))
|
||||
const sourceCodexHome = await mkdtemp(
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
} from '../../../src/lib/agents/acpx-runtime'
|
||||
import type { AgentDefinition } from '../../../src/lib/agents/agent-types'
|
||||
import {
|
||||
ClaudeRuntime,
|
||||
CodexRuntime,
|
||||
getAgentRuntimeRegistry,
|
||||
HermesContainerRuntime,
|
||||
resetAgentRuntimeRegistry,
|
||||
@@ -42,15 +44,23 @@ describe('AcpxRuntime', () => {
|
||||
|
||||
it('uses acpx/runtime to ensure a session and stream a turn', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'browseros-acpx-runtime-'))
|
||||
const browserosDir = await mkdtemp(
|
||||
join(tmpdir(), 'browseros-acpx-browseros-'),
|
||||
)
|
||||
const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-'))
|
||||
tempDirs.push(cwd, stateDir)
|
||||
tempDirs.push(cwd, browserosDir, stateDir)
|
||||
const calls: Array<{ method: string; input: unknown }> = []
|
||||
const runtimeFactory = (options: AcpRuntimeOptions): AcpxCoreRuntime => {
|
||||
calls.push({ method: 'createRuntime', input: options })
|
||||
return createFakeAcpRuntime(calls)
|
||||
}
|
||||
|
||||
const runtime = new AcpxRuntime({ cwd, stateDir, runtimeFactory })
|
||||
const runtime = new AcpxRuntime({
|
||||
browserosDir,
|
||||
cwd,
|
||||
stateDir,
|
||||
runtimeFactory,
|
||||
})
|
||||
const agent: AgentDefinition = {
|
||||
id: 'agent-1',
|
||||
name: 'Review bot',
|
||||
@@ -78,8 +88,15 @@ describe('AcpxRuntime', () => {
|
||||
'setConfigOption',
|
||||
'startTurn',
|
||||
])
|
||||
const expectedCwd = join(
|
||||
browserosDir,
|
||||
'vm',
|
||||
'codex',
|
||||
'harness',
|
||||
'workspace',
|
||||
)
|
||||
expect(calls[0]?.input).toMatchObject({
|
||||
cwd,
|
||||
cwd: expectedCwd,
|
||||
permissionMode: 'approve-all',
|
||||
nonInteractivePermissions: 'fail',
|
||||
})
|
||||
@@ -87,7 +104,7 @@ describe('AcpxRuntime', () => {
|
||||
sessionKey: expect.stringMatching(/^agent:agent-1:main:[a-f0-9]{16}$/),
|
||||
agent: 'codex',
|
||||
mode: 'persistent',
|
||||
cwd,
|
||||
cwd: expectedCwd,
|
||||
})
|
||||
expect(calls[2]?.input).toMatchObject({
|
||||
key: 'reasoning_effort',
|
||||
@@ -152,7 +169,13 @@ describe('AcpxRuntime', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const expectedCwd = join(browserosDir, 'agents', 'harness', 'workspace')
|
||||
const expectedCwd = join(
|
||||
browserosDir,
|
||||
'vm',
|
||||
'claude',
|
||||
'harness',
|
||||
'workspace',
|
||||
)
|
||||
expect(calls[0]?.input).toMatchObject({ cwd: expectedCwd })
|
||||
expect(calls[1]?.input).toMatchObject({ cwd: expectedCwd })
|
||||
expect((calls[1]?.input as { sessionKey: string }).sessionKey).toMatch(
|
||||
@@ -167,7 +190,7 @@ describe('AcpxRuntime', () => {
|
||||
expect(text).toContain('<user_request>\nremember this\n</user_request>')
|
||||
})
|
||||
|
||||
it('uses selected cwd in the runtime fingerprint', async () => {
|
||||
it('ignores selected host cwd for container runtime fingerprint', async () => {
|
||||
const browserosDir = await mkdtemp(
|
||||
join(tmpdir(), 'browseros-acpx-browseros-'),
|
||||
)
|
||||
@@ -196,14 +219,21 @@ describe('AcpxRuntime', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(calls[0]?.input).toMatchObject({ cwd: selected })
|
||||
expect(calls[1]?.input).toMatchObject({ cwd: selected })
|
||||
const expectedCwd = join(
|
||||
browserosDir,
|
||||
'vm',
|
||||
'codex',
|
||||
'harness',
|
||||
'workspace',
|
||||
)
|
||||
expect(calls[0]?.input).toMatchObject({ cwd: expectedCwd })
|
||||
expect(calls[1]?.input).toMatchObject({ cwd: expectedCwd })
|
||||
expect((calls[1]?.input as { sessionKey: string }).sessionKey).toMatch(
|
||||
/^agent:agent-1:main:[a-f0-9]{16}$/,
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces a clear error when selected cwd no longer exists', async () => {
|
||||
it('does not surface host-cwd errors for container runtimes because selected host cwd is ignored', async () => {
|
||||
const browserosDir = await mkdtemp(
|
||||
join(tmpdir(), 'browseros-acpx-browseros-'),
|
||||
)
|
||||
@@ -221,8 +251,8 @@ describe('AcpxRuntime', () => {
|
||||
})
|
||||
const agent = makeAgent({ id: 'agent-1', adapter: 'codex' })
|
||||
|
||||
await expect(
|
||||
runtime.send({
|
||||
await collectStream(
|
||||
await runtime.send({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
sessionKey: agent.sessionKey,
|
||||
@@ -230,8 +260,10 @@ describe('AcpxRuntime', () => {
|
||||
message: 'work here',
|
||||
permissionMode: 'approve-all',
|
||||
}),
|
||||
).rejects.toThrow(`Selected workspace does not exist: ${missingCwd}`)
|
||||
expect(calls).toEqual([])
|
||||
)
|
||||
expect(calls[0]?.input).toMatchObject({
|
||||
cwd: join(browserosDir, 'vm', 'codex', 'harness', 'workspace'),
|
||||
})
|
||||
})
|
||||
|
||||
it('loads history from the latest runtime-state session key', async () => {
|
||||
@@ -727,10 +759,15 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
})
|
||||
|
||||
it('continues the turn when runtime config control is unavailable', 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({
|
||||
cwd: '/tmp/browseros-acpx-runtime',
|
||||
stateDir: '/tmp/browseros-acpx-state',
|
||||
browserosDir,
|
||||
stateDir,
|
||||
runtimeFactory: () => createFakeAcpRuntime(calls, { failConfig: true }),
|
||||
})
|
||||
const agent: AgentDefinition = {
|
||||
@@ -769,10 +806,15 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
})
|
||||
|
||||
it('configures BrowserOS MCP and wraps turns with browser instructions', 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({
|
||||
cwd: '/tmp/browseros-acpx-runtime',
|
||||
stateDir: '/tmp/browseros-acpx-state',
|
||||
browserosDir,
|
||||
stateDir,
|
||||
browserosServerPort: 9321,
|
||||
runtimeFactory: (options) => {
|
||||
calls.push({ method: 'createRuntime', input: options })
|
||||
@@ -804,7 +846,7 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
{
|
||||
type: 'http',
|
||||
name: 'browseros',
|
||||
url: 'http://127.0.0.1:9321/mcp',
|
||||
url: 'http://host.containers.internal:9321/mcp',
|
||||
headers: [],
|
||||
},
|
||||
],
|
||||
@@ -819,10 +861,15 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
})
|
||||
|
||||
it('escapes user request tag boundaries in wrapped prompts', 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({
|
||||
cwd: '/tmp/browseros-acpx-runtime',
|
||||
stateDir: '/tmp/browseros-acpx-state',
|
||||
browserosDir,
|
||||
stateDir,
|
||||
runtimeFactory: () => createFakeAcpRuntime(calls),
|
||||
})
|
||||
const agent: AgentDefinition = {
|
||||
@@ -856,10 +903,15 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
})
|
||||
|
||||
it('does not pass native CLI permission flags to ACP adapters', 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({
|
||||
cwd: '/tmp/browseros-acpx-runtime',
|
||||
stateDir: '/tmp/browseros-acpx-state',
|
||||
browserosDir,
|
||||
stateDir,
|
||||
runtimeFactory: (options) => {
|
||||
calls.push({ method: 'createRuntime', input: options })
|
||||
return createFakeAcpRuntime(calls)
|
||||
@@ -924,10 +976,11 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
const command =
|
||||
getCreateRuntimeOptions(calls).agentRegistry.resolve('claude')
|
||||
expect(command).toContain('env AGENT_HOME=')
|
||||
expect(command).toContain('/vm/claude/harness/')
|
||||
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.
|
||||
// Spawn must go through the installed ACP adapter, not a bare `claude`
|
||||
// binary.
|
||||
expect(command).toContain('claude-agent-acp')
|
||||
})
|
||||
|
||||
@@ -962,12 +1015,126 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
getCreateRuntimeOptions(calls).agentRegistry.resolve('codex')
|
||||
expect(command).toContain('env AGENT_HOME=')
|
||||
expect(command).toContain('CODEX_HOME=')
|
||||
expect(command).toContain('/vm/codex/harness/')
|
||||
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.
|
||||
// Spawn must go through the installed ACP adapter, not a bare `codex`
|
||||
// binary.
|
||||
expect(command).toContain('codex-acp')
|
||||
})
|
||||
|
||||
it('resolves the Claude adapter to a container `nerdctl exec claude-agent-acp` command when a ClaudeRuntime 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 claudeRuntime = new ClaudeRuntime(fakeManagedDeps, {
|
||||
browserosDir,
|
||||
claudeHarnessHostDir: join(browserosDir, 'vm', 'claude', 'harness'),
|
||||
})
|
||||
getAgentRuntimeRegistry().register(claudeRuntime)
|
||||
|
||||
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: 'claude' })
|
||||
|
||||
await collectStream(
|
||||
await runtime.send({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
sessionKey: agent.sessionKey,
|
||||
message: 'hi',
|
||||
permissionMode: 'approve-all',
|
||||
}),
|
||||
)
|
||||
|
||||
const command =
|
||||
getCreateRuntimeOptions(calls).agentRegistry.resolve('claude')
|
||||
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('browseros-claude-claude-agent-1')
|
||||
expect(command).toContain('claude-agent-acp')
|
||||
expect(command).toContain('AGENT_HOME=')
|
||||
expect(command).toContain('/vm/claude/harness/')
|
||||
expect(command).not.toContain('CODEX_HOME=')
|
||||
expect(command).not.toContain('CLAUDE_CONFIG_DIR=')
|
||||
})
|
||||
|
||||
it('resolves the Codex adapter to a container `nerdctl exec codex-acp` command when a CodexRuntime 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 codexRuntime = new CodexRuntime(fakeManagedDeps, {
|
||||
browserosDir,
|
||||
codexHarnessHostDir: join(browserosDir, 'vm', 'codex', 'harness'),
|
||||
})
|
||||
getAgentRuntimeRegistry().register(codexRuntime)
|
||||
|
||||
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: 'codex' })
|
||||
|
||||
await collectStream(
|
||||
await runtime.send({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
sessionKey: agent.sessionKey,
|
||||
message: 'hi',
|
||||
permissionMode: 'approve-all',
|
||||
}),
|
||||
)
|
||||
|
||||
const command =
|
||||
getCreateRuntimeOptions(calls).agentRegistry.resolve('codex')
|
||||
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('browseros-codex-codex-agent-1')
|
||||
expect(command).toContain('codex-acp')
|
||||
expect(command).toContain('CODEX_HOME=')
|
||||
expect(command).toContain('/vm/codex/harness/')
|
||||
expect(command).not.toContain('CLAUDE_CONFIG_DIR=')
|
||||
})
|
||||
|
||||
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-'),
|
||||
@@ -1219,10 +1386,15 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
})
|
||||
|
||||
it('sets Claude approve-all sessions to bypass permissions before starting a turn', 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({
|
||||
cwd: '/tmp/browseros-acpx-runtime',
|
||||
stateDir: '/tmp/browseros-acpx-state',
|
||||
browserosDir,
|
||||
stateDir,
|
||||
runtimeFactory: () => createFakeAcpRuntime(calls),
|
||||
})
|
||||
const agent: AgentDefinition = {
|
||||
@@ -1256,10 +1428,15 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
})
|
||||
|
||||
it('continues Claude approve-all turns when mode control is unavailable', 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({
|
||||
cwd: '/tmp/browseros-acpx-runtime',
|
||||
stateDir: '/tmp/browseros-acpx-state',
|
||||
browserosDir,
|
||||
stateDir,
|
||||
runtimeFactory: () =>
|
||||
createFakeAcpRuntime(calls, { omitModeControl: true }),
|
||||
})
|
||||
@@ -1314,10 +1491,15 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
})
|
||||
|
||||
it('reuses cached runtime instances across per-turn timeouts', 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({
|
||||
cwd: '/tmp/browseros-acpx-runtime',
|
||||
stateDir: '/tmp/browseros-acpx-state',
|
||||
browserosDir,
|
||||
stateDir,
|
||||
runtimeFactory: (options) => {
|
||||
calls.push({ method: 'createRuntime', input: options })
|
||||
return createFakeAcpRuntime(calls)
|
||||
@@ -1393,7 +1575,8 @@ async function createLatestRuntimeStateForTest(input: {
|
||||
await saveLatestRuntimeState(
|
||||
join(
|
||||
input.browserosDir,
|
||||
'agents',
|
||||
'vm',
|
||||
'codex',
|
||||
'harness',
|
||||
'runtime-state',
|
||||
`${input.agentId}.json`,
|
||||
@@ -1401,10 +1584,11 @@ async function createLatestRuntimeStateForTest(input: {
|
||||
{
|
||||
sessionId: 'main',
|
||||
runtimeSessionKey: input.runtimeSessionKey,
|
||||
cwd: join(input.browserosDir, 'agents', 'harness', 'workspace'),
|
||||
cwd: join(input.browserosDir, 'vm', 'codex', 'harness', 'workspace'),
|
||||
agentHome: join(
|
||||
input.browserosDir,
|
||||
'agents',
|
||||
'vm',
|
||||
'codex',
|
||||
'harness',
|
||||
input.agentId,
|
||||
'home',
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
CLAUDE_CONTAINER_NAME,
|
||||
CLAUDE_IMAGE,
|
||||
} from '../../../../../../packages/shared/src/constants/claude'
|
||||
import {
|
||||
ClaudeRuntime,
|
||||
configureClaudeRuntime,
|
||||
getAgentRuntimeRegistry,
|
||||
getClaudeRuntime,
|
||||
prepareClaudeCodeContext,
|
||||
resetAgentRuntimeRegistry,
|
||||
} from '../../../../src/lib/agents/runtime'
|
||||
import type {
|
||||
ManagedContainerDeps,
|
||||
MountRoot,
|
||||
} from '../../../../src/lib/container/managed'
|
||||
import type {
|
||||
ContainerInfo,
|
||||
ContainerSpec,
|
||||
} from '../../../../src/lib/container/types'
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
function makeDeps(opts: { lockDir: string }): {
|
||||
deps: ManagedContainerDeps
|
||||
getCapturedSpec: () => ContainerSpec | null
|
||||
} {
|
||||
let capturedSpec: ContainerSpec | null = null
|
||||
const fakeCli = {
|
||||
inspectContainer: async (): Promise<ContainerInfo | null> => ({
|
||||
id: 'cid',
|
||||
name: CLAUDE_CONTAINER_NAME,
|
||||
image: CLAUDE_IMAGE,
|
||||
status: 'running',
|
||||
running: true,
|
||||
}),
|
||||
removeContainer: async () => {},
|
||||
waitForContainerNameRelease: async () => {},
|
||||
createContainer: async (spec: ContainerSpec) => {
|
||||
capturedSpec = spec
|
||||
},
|
||||
startContainer: async () => {},
|
||||
waitForContainerRunning: async () => {},
|
||||
exec: async () => 0,
|
||||
}
|
||||
const fakeLoader = { ensureImageLoaded: async () => {} }
|
||||
const fakeVm = {
|
||||
ensureReady: async () => {},
|
||||
getDefaultGateway: async () => '192.168.5.2',
|
||||
}
|
||||
const deps: ManagedContainerDeps = {
|
||||
cli: fakeCli as unknown as ManagedContainerDeps['cli'],
|
||||
loader: fakeLoader as unknown as ManagedContainerDeps['loader'],
|
||||
vm: fakeVm as unknown as ManagedContainerDeps['vm'],
|
||||
limactlPath: '/opt/homebrew/bin/limactl',
|
||||
limaHome: '/Users/dev/.browseros/lima',
|
||||
vmName: 'browseros-vm',
|
||||
lockDir: opts.lockDir,
|
||||
}
|
||||
return { deps, getCapturedSpec: () => capturedSpec }
|
||||
}
|
||||
|
||||
describe('ClaudeRuntime', () => {
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
)
|
||||
tempDirs.length = 0
|
||||
resetAgentRuntimeRegistry()
|
||||
})
|
||||
|
||||
function mkTempDirSync(prefix: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
it('declares the canonical Claude descriptor', () => {
|
||||
const runtime = new ClaudeRuntime(
|
||||
makeDeps({ lockDir: mkTempDirSync('claude-runtime-') }).deps,
|
||||
{
|
||||
browserosDir: '/tmp/browseros',
|
||||
claudeHarnessHostDir: '/tmp/browseros/vm/claude/harness',
|
||||
},
|
||||
)
|
||||
expect(runtime.descriptor.adapterId).toBe('claude')
|
||||
expect(runtime.descriptor.kind).toBe('container')
|
||||
expect(runtime.descriptor.containerName).toBe(CLAUDE_CONTAINER_NAME)
|
||||
expect(runtime.descriptor.defaultImage).toBe(CLAUDE_IMAGE)
|
||||
expect(runtime.descriptor.defaultImage).toBe(
|
||||
'docker.io/library/node:20-bookworm-slim',
|
||||
)
|
||||
expect(runtime.descriptor.platforms).toContain('darwin')
|
||||
expect(runtime.descriptor.readinessProbe?.timeoutMs).toBe(120_000)
|
||||
})
|
||||
|
||||
it('mountRoots maps the VM-backed harness dir into the container at the same path', () => {
|
||||
const runtime = new ClaudeRuntime(
|
||||
makeDeps({ lockDir: mkTempDirSync('claude-runtime-') }).deps,
|
||||
{
|
||||
browserosDir: '/tmp/browseros',
|
||||
claudeHarnessHostDir: '/tmp/browseros/vm/claude/harness',
|
||||
},
|
||||
)
|
||||
const mounts: readonly MountRoot[] = (
|
||||
runtime as unknown as { mountRoots(): readonly MountRoot[] }
|
||||
).mountRoots()
|
||||
expect(mounts).toEqual([
|
||||
{
|
||||
hostPath: '/tmp/browseros/vm/claude/harness',
|
||||
containerPath: '/tmp/browseros/vm/claude/harness',
|
||||
kind: 'shared',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('builds a ContainerSpec that installs Claude Code using the devcontainer npm path', async () => {
|
||||
const lockDir = mkTempDirSync('claude-runtime-')
|
||||
const { deps, getCapturedSpec } = makeDeps({ lockDir })
|
||||
const runtime = new ClaudeRuntime(deps, {
|
||||
browserosDir: '/tmp/browseros',
|
||||
claudeHarnessHostDir: '/tmp/browseros/vm/claude/harness',
|
||||
})
|
||||
|
||||
await runtime.start()
|
||||
|
||||
const spec = getCapturedSpec()
|
||||
if (!spec) throw new Error('createContainer was never called')
|
||||
expect(spec.entrypoint).toBe('/bin/sh')
|
||||
expect(spec.command).toEqual([
|
||||
'-c',
|
||||
'npm install -g @anthropic-ai/claude-code@latest @agentclientprotocol/claude-agent-acp@^0.31.0 && exec sleep infinity',
|
||||
])
|
||||
expect(spec.addHosts).toContain('host.containers.internal:192.168.5.2')
|
||||
expect(spec.mounts).toContainEqual({
|
||||
source: '/mnt/browseros/vm/claude/harness',
|
||||
target: '/tmp/browseros/vm/claude/harness',
|
||||
})
|
||||
})
|
||||
|
||||
it('getPerAgentHomeDir resolves the VM-backed agent home path', () => {
|
||||
const runtime = new ClaudeRuntime(
|
||||
makeDeps({ lockDir: mkTempDirSync('claude-runtime-') }).deps,
|
||||
{
|
||||
browserosDir: '/tmp/browseros',
|
||||
claudeHarnessHostDir: '/tmp/browseros/vm/claude/harness',
|
||||
},
|
||||
)
|
||||
expect(runtime.getPerAgentHomeDir('agent-7')).toBe(
|
||||
'/tmp/browseros/vm/claude/harness/agent-7/home',
|
||||
)
|
||||
})
|
||||
|
||||
it('getAcpExecSpec runs the Claude ACP adapter inside the container', () => {
|
||||
const runtime = new ClaudeRuntime(
|
||||
makeDeps({ lockDir: mkTempDirSync('claude-runtime-') }).deps,
|
||||
{
|
||||
browserosDir: '/tmp/browseros',
|
||||
claudeHarnessHostDir: '/tmp/browseros/vm/claude/harness',
|
||||
},
|
||||
)
|
||||
|
||||
const spec = runtime.getAcpExecSpec({ AGENT_HOME: '/tmp/agent' })
|
||||
|
||||
expect(spec.argv).toEqual(['claude-agent-acp'])
|
||||
expect(spec.env).toEqual({ AGENT_HOME: '/tmp/agent' })
|
||||
})
|
||||
|
||||
it('prepareTurnContext sets VM-backed 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',
|
||||
})
|
||||
const agentHome = join(
|
||||
browserosDir,
|
||||
'vm',
|
||||
'claude',
|
||||
'harness',
|
||||
'claude-agent',
|
||||
'home',
|
||||
)
|
||||
expect(prepared.commandEnv).toEqual({
|
||||
AGENT_HOME: agentHome,
|
||||
HOME: agentHome,
|
||||
})
|
||||
expect(prepared.cwd).toBe(
|
||||
join(browserosDir, 'vm', 'claude', 'harness', 'workspace'),
|
||||
)
|
||||
expect(prepared.runPrompt).toContain(
|
||||
`AGENT_HOME=${join(
|
||||
browserosDir,
|
||||
'vm',
|
||||
'claude',
|
||||
'harness',
|
||||
'claude-agent',
|
||||
'home',
|
||||
)}`,
|
||||
)
|
||||
expect(prepared.commandEnv).not.toHaveProperty('CODEX_HOME')
|
||||
expect(prepared.browserosMcpHost).toBe('host.containers.internal')
|
||||
expect(prepared.useBrowserosMcp).toBe(true)
|
||||
})
|
||||
|
||||
it('prepareTurnContext ignores selected host cwd for container safety', async () => {
|
||||
const browserosDir = await mkdtemp(join(tmpdir(), 'browseros-claude-'))
|
||||
const selectedCwd = await mkdtemp(join(tmpdir(), 'selected-cwd-'))
|
||||
tempDirs.push(browserosDir, selectedCwd)
|
||||
const prepared = await prepareClaudeCodeContext({
|
||||
browserosDir,
|
||||
agent: makeAgent('claude-agent'),
|
||||
sessionId: 'main',
|
||||
sessionKey: 'agent:claude-agent:main',
|
||||
cwdOverride: selectedCwd,
|
||||
isSelectedCwd: true,
|
||||
message: 'hi',
|
||||
})
|
||||
expect(prepared.cwd).toBe(
|
||||
join(browserosDir, 'vm', 'claude', 'harness', 'workspace'),
|
||||
)
|
||||
expect(prepared.cwd).not.toBe(selectedCwd)
|
||||
})
|
||||
|
||||
it('buildExecArgv produces a limactl/nerdctl Claude ACP command', () => {
|
||||
const runtime = new ClaudeRuntime(
|
||||
makeDeps({ lockDir: mkTempDirSync('claude-runtime-') }).deps,
|
||||
{
|
||||
browserosDir: '/tmp/browseros',
|
||||
claudeHarnessHostDir: '/tmp/browseros/vm/claude/harness',
|
||||
},
|
||||
)
|
||||
const out = runtime.buildExecArgv(
|
||||
runtime.getAcpExecSpec({
|
||||
AGENT_HOME: '/tmp/browseros/vm/claude/harness/agent/home',
|
||||
}),
|
||||
)
|
||||
expect(out).toContain('LIMA_HOME=/Users/dev/.browseros/lima')
|
||||
expect(out).toContain('nerdctl exec -i')
|
||||
expect(out).toContain(CLAUDE_CONTAINER_NAME)
|
||||
expect(out).toContain('claude-agent-acp')
|
||||
expect(out).toContain(
|
||||
'-e AGENT_HOME=/tmp/browseros/vm/claude/harness/agent/home',
|
||||
)
|
||||
})
|
||||
|
||||
it('prepareTurnContext default workspace is under the Claude VM harness', 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.cwd).toBe(
|
||||
join(browserosDir, 'vm', 'claude', 'harness', 'workspace'),
|
||||
)
|
||||
})
|
||||
|
||||
describe('configureClaudeRuntime', () => {
|
||||
let originalPlatform: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalPlatform = process.platform
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform })
|
||||
})
|
||||
|
||||
it('registers a runtime in the registry', () => {
|
||||
const browserosDir = '/tmp/browseros'
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
const runtime = configureClaudeRuntime({ browserosDir })
|
||||
expect(runtime).toBeInstanceOf(ClaudeRuntime)
|
||||
expect(getClaudeRuntime()).toBe(runtime)
|
||||
expect(getAgentRuntimeRegistry().get('claude')).toBe(runtime)
|
||||
})
|
||||
|
||||
it('throws on duplicate registration', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
configureClaudeRuntime({ browserosDir: '/tmp/browseros' })
|
||||
expect(() =>
|
||||
configureClaudeRuntime({ browserosDir: '/tmp/browseros' }),
|
||||
).toThrow(/already registered/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* @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,283 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
CODEX_CONTAINER_NAME,
|
||||
CODEX_IMAGE,
|
||||
} from '../../../../../../packages/shared/src/constants/codex'
|
||||
import {
|
||||
CodexRuntime,
|
||||
configureCodexRuntime,
|
||||
getAgentRuntimeRegistry,
|
||||
getCodexRuntime,
|
||||
prepareCodexContext,
|
||||
resetAgentRuntimeRegistry,
|
||||
} from '../../../../src/lib/agents/runtime'
|
||||
import type {
|
||||
ManagedContainerDeps,
|
||||
MountRoot,
|
||||
} from '../../../../src/lib/container/managed'
|
||||
import type {
|
||||
ContainerInfo,
|
||||
ContainerSpec,
|
||||
} from '../../../../src/lib/container/types'
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
function makeDeps(opts: { lockDir: string }): {
|
||||
deps: ManagedContainerDeps
|
||||
getCapturedSpec: () => ContainerSpec | null
|
||||
getExecCommands: () => string[][]
|
||||
} {
|
||||
let capturedSpec: ContainerSpec | null = null
|
||||
const execCommands: string[][] = []
|
||||
const fakeCli = {
|
||||
inspectContainer: async (): Promise<ContainerInfo | null> => ({
|
||||
id: 'cid',
|
||||
name: CODEX_CONTAINER_NAME,
|
||||
image: CODEX_IMAGE,
|
||||
status: 'running',
|
||||
running: true,
|
||||
}),
|
||||
removeContainer: async () => {},
|
||||
waitForContainerNameRelease: async () => {},
|
||||
createContainer: async (spec: ContainerSpec) => {
|
||||
capturedSpec = spec
|
||||
},
|
||||
startContainer: async () => {},
|
||||
waitForContainerRunning: async () => {},
|
||||
exec: async (_containerName: string, argv: string[]) => {
|
||||
execCommands.push(argv)
|
||||
return 0
|
||||
},
|
||||
}
|
||||
const fakeLoader = { ensureImageLoaded: async () => {} }
|
||||
const fakeVm = {
|
||||
ensureReady: async () => {},
|
||||
getDefaultGateway: async () => '192.168.5.2',
|
||||
}
|
||||
const deps: ManagedContainerDeps = {
|
||||
cli: fakeCli as unknown as ManagedContainerDeps['cli'],
|
||||
loader: fakeLoader as unknown as ManagedContainerDeps['loader'],
|
||||
vm: fakeVm as unknown as ManagedContainerDeps['vm'],
|
||||
limactlPath: '/opt/homebrew/bin/limactl',
|
||||
limaHome: '/Users/dev/.browseros/lima',
|
||||
vmName: 'browseros-vm',
|
||||
lockDir: opts.lockDir,
|
||||
}
|
||||
return {
|
||||
deps,
|
||||
getCapturedSpec: () => capturedSpec,
|
||||
getExecCommands: () => execCommands,
|
||||
}
|
||||
}
|
||||
|
||||
describe('CodexRuntime', () => {
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
)
|
||||
tempDirs.length = 0
|
||||
resetAgentRuntimeRegistry()
|
||||
})
|
||||
|
||||
function mkTempDirSync(prefix: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
it('declares the canonical Codex descriptor', () => {
|
||||
const runtime = new CodexRuntime(
|
||||
makeDeps({ lockDir: mkTempDirSync('codex-runtime-') }).deps,
|
||||
{
|
||||
browserosDir: '/tmp/browseros',
|
||||
codexHarnessHostDir: '/tmp/browseros/vm/codex/harness',
|
||||
},
|
||||
)
|
||||
expect(runtime.descriptor.adapterId).toBe('codex')
|
||||
expect(runtime.descriptor.kind).toBe('container')
|
||||
expect(runtime.descriptor.containerName).toBe(CODEX_CONTAINER_NAME)
|
||||
expect(runtime.descriptor.defaultImage).toBe(CODEX_IMAGE)
|
||||
expect(runtime.descriptor.defaultImage).toBe(
|
||||
'docker.io/library/node:20-bookworm-slim',
|
||||
)
|
||||
expect(runtime.descriptor.readinessProbe?.timeoutMs).toBe(120_000)
|
||||
})
|
||||
|
||||
it('mountRoots maps the VM-backed harness dir into the container at the same path', () => {
|
||||
const runtime = new CodexRuntime(
|
||||
makeDeps({ lockDir: mkTempDirSync('codex-runtime-') }).deps,
|
||||
{
|
||||
browserosDir: '/tmp/browseros',
|
||||
codexHarnessHostDir: '/tmp/browseros/vm/codex/harness',
|
||||
},
|
||||
)
|
||||
const mounts: readonly MountRoot[] = (
|
||||
runtime as unknown as { mountRoots(): readonly MountRoot[] }
|
||||
).mountRoots()
|
||||
expect(mounts).toEqual([
|
||||
{
|
||||
hostPath: '/tmp/browseros/vm/codex/harness',
|
||||
containerPath: '/tmp/browseros/vm/codex/harness',
|
||||
kind: 'shared',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('builds a ContainerSpec with Codex CLI and ACP adapter install + VM harness mount + add-host', async () => {
|
||||
const { deps, getCapturedSpec, getExecCommands } = makeDeps({
|
||||
lockDir: mkTempDirSync('codex-runtime-'),
|
||||
})
|
||||
const runtime = new CodexRuntime(deps, {
|
||||
browserosDir: '/tmp/browseros',
|
||||
codexHarnessHostDir: '/tmp/browseros/vm/codex/harness',
|
||||
})
|
||||
|
||||
await runtime.start()
|
||||
|
||||
const spec = getCapturedSpec()
|
||||
if (!spec) throw new Error('createContainer was never called')
|
||||
expect(spec.entrypoint).toBe('/bin/sh')
|
||||
expect(spec.command).toEqual([
|
||||
'-c',
|
||||
'apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* && npm install -g @openai/codex@latest @zed-industries/codex-acp@^0.12.0 && exec sleep infinity',
|
||||
])
|
||||
expect(spec.addHosts).toContain('host.containers.internal:192.168.5.2')
|
||||
expect(spec.mounts).toContainEqual({
|
||||
source: '/mnt/browseros/vm/codex/harness',
|
||||
target: '/tmp/browseros/vm/codex/harness',
|
||||
})
|
||||
expect(getExecCommands()).toContainEqual([
|
||||
'sh',
|
||||
'-lc',
|
||||
'command -v codex >/dev/null && command -v codex-acp >/dev/null && codex-acp --help >/dev/null',
|
||||
])
|
||||
})
|
||||
|
||||
it('getAcpExecSpec runs the Codex ACP adapter inside the container', () => {
|
||||
const runtime = new CodexRuntime(
|
||||
makeDeps({ lockDir: mkTempDirSync('codex-runtime-') }).deps,
|
||||
{
|
||||
browserosDir: '/tmp/browseros',
|
||||
codexHarnessHostDir: '/tmp/browseros/vm/codex/harness',
|
||||
},
|
||||
)
|
||||
|
||||
const spec = runtime.getAcpExecSpec({ CODEX_HOME: '/tmp/codex' })
|
||||
|
||||
expect(spec.argv).toEqual(['codex-acp'])
|
||||
expect(spec.env).toEqual({ CODEX_HOME: '/tmp/codex' })
|
||||
})
|
||||
|
||||
it('prepareTurnContext sets VM-backed 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, 'vm', 'codex', 'harness', 'codex-agent', 'home'),
|
||||
)
|
||||
expect(prepared.commandEnv.CODEX_HOME).toBe(
|
||||
join(
|
||||
browserosDir,
|
||||
'vm',
|
||||
'codex',
|
||||
'harness',
|
||||
'codex-agent',
|
||||
'runtime',
|
||||
'codex-home',
|
||||
),
|
||||
)
|
||||
const codexHomeStat = await stat(prepared.commandEnv.CODEX_HOME)
|
||||
expect(codexHomeStat.isDirectory()).toBe(true)
|
||||
expect(prepared.cwd).toBe(
|
||||
join(browserosDir, 'vm', 'codex', 'harness', 'workspace'),
|
||||
)
|
||||
expect(prepared.browserosMcpHost).toBe('host.containers.internal')
|
||||
expect(prepared.useBrowserosMcp).toBe(true)
|
||||
})
|
||||
|
||||
it('buildExecArgv produces a limactl/nerdctl Codex ACP command', () => {
|
||||
const runtime = new CodexRuntime(
|
||||
makeDeps({ lockDir: mkTempDirSync('codex-runtime-') }).deps,
|
||||
{
|
||||
browserosDir: '/tmp/browseros',
|
||||
codexHarnessHostDir: '/tmp/browseros/vm/codex/harness',
|
||||
},
|
||||
)
|
||||
const out = runtime.buildExecArgv(
|
||||
runtime.getAcpExecSpec({
|
||||
CODEX_HOME: '/tmp/browseros/vm/codex/harness/agent/runtime/codex-home',
|
||||
}),
|
||||
)
|
||||
expect(out).toContain('LIMA_HOME=/Users/dev/.browseros/lima')
|
||||
expect(out).toContain('nerdctl exec -i')
|
||||
expect(out).toContain(CODEX_CONTAINER_NAME)
|
||||
expect(out).toContain('codex-acp')
|
||||
expect(out).toContain(
|
||||
'-e CODEX_HOME=/tmp/browseros/vm/codex/harness/agent/runtime/codex-home',
|
||||
)
|
||||
})
|
||||
|
||||
describe('configureCodexRuntime', () => {
|
||||
let originalPlatform: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalPlatform = process.platform
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform })
|
||||
})
|
||||
|
||||
it('registers a runtime in the registry', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
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', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
configureCodexRuntime({ browserosDir: '/tmp/browseros' })
|
||||
expect(() =>
|
||||
configureCodexRuntime({ browserosDir: '/tmp/browseros' }),
|
||||
).toThrow(/already registered/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* @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/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,18 +3,20 @@
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
|
||||
import {
|
||||
ActionNotSupportedError,
|
||||
ContainerAgentRuntime,
|
||||
type RuntimeCapability,
|
||||
type RuntimeStatusSnapshot,
|
||||
startContainerRuntimeBestEffort,
|
||||
} from '../../../../src/lib/agents/runtime'
|
||||
import type {
|
||||
ManagedContainerDeps,
|
||||
MountRoot,
|
||||
} from '../../../../src/lib/container/managed'
|
||||
import type { ContainerSpec } from '../../../../src/lib/container/types'
|
||||
import { logger } from '../../../../src/lib/logger'
|
||||
|
||||
interface Call {
|
||||
kind: 'install' | 'start' | 'stop' | 'restart' | 'reset'
|
||||
@@ -87,6 +89,10 @@ function makeDeps(): ManagedContainerDeps {
|
||||
}
|
||||
|
||||
describe('ContainerAgentRuntime', () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
it('default capabilities cover lifecycle + reset levels + logs', () => {
|
||||
const r = new TestRuntime(makeDeps())
|
||||
expect(r.getCapabilities()).toEqual([
|
||||
@@ -185,4 +191,29 @@ describe('ContainerAgentRuntime', () => {
|
||||
).rejects.toBeInstanceOf(ActionNotSupportedError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startContainerRuntimeBestEffort', () => {
|
||||
it('configures a container runtime and schedules install + start', () => {
|
||||
const r = new TestRuntime(makeDeps())
|
||||
|
||||
const result = startContainerRuntimeBestEffort(() => r)
|
||||
|
||||
expect(result).toBe(r)
|
||||
expect(r.calls.map((c) => c.kind)).toEqual(['install', 'start'])
|
||||
})
|
||||
|
||||
it('returns null when configuration throws', () => {
|
||||
const loggerWarn = spyOn(logger, 'warn').mockImplementation(() => {})
|
||||
|
||||
const result = startContainerRuntimeBestEffort(() => {
|
||||
throw new Error('limactl missing')
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(loggerWarn).toHaveBeenCalledWith(
|
||||
'Container runtime configuration failed, continuing without it',
|
||||
{ error: 'limactl missing' },
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,9 +19,7 @@ import {
|
||||
getHermesRuntime,
|
||||
HermesContainerRuntime,
|
||||
resetAgentRuntimeRegistry,
|
||||
startHermesRuntimeBestEffort,
|
||||
} from '../../../../src/lib/agents/runtime'
|
||||
import type { RuntimeAction } from '../../../../src/lib/agents/runtime/types'
|
||||
import type {
|
||||
ManagedContainerDeps,
|
||||
MountRoot,
|
||||
@@ -159,6 +157,7 @@ describe('HermesContainerRuntime', () => {
|
||||
const runtime = new HermesContainerRuntime(deps, {
|
||||
hermesHarnessHostDir: '/host/browseros/vm/hermes/harness',
|
||||
})
|
||||
runtime.descriptor.readinessProbe = { timeoutMs: 20, intervalMs: 1 }
|
||||
await expect(runtime.start()).rejects.toThrow(/probe failed/i)
|
||||
expect(runtime.getState()).toBe('errored')
|
||||
})
|
||||
@@ -251,77 +250,4 @@ describe('HermesContainerRuntime', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startHermesRuntimeBestEffort', () => {
|
||||
it('configures Hermes and schedules install + start actions', async () => {
|
||||
const actions: RuntimeAction[] = []
|
||||
const runtime = {
|
||||
executeAction: async (action: RuntimeAction) => {
|
||||
actions.push(action)
|
||||
},
|
||||
} as HermesContainerRuntime
|
||||
|
||||
const result = startHermesRuntimeBestEffort({
|
||||
resourcesDir: '/Applications/BrowserOS.app/Contents/Resources',
|
||||
configureRuntime: (options) => {
|
||||
expect(options).toEqual({
|
||||
resourcesDir: '/Applications/BrowserOS.app/Contents/Resources',
|
||||
})
|
||||
return runtime
|
||||
},
|
||||
onError: (phase, error) => {
|
||||
throw new Error(`${phase}: ${String(error)}`)
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(runtime)
|
||||
expect(actions).toEqual([{ type: 'install' }, { type: 'start' }])
|
||||
})
|
||||
|
||||
it('returns null when Hermes configuration throws', () => {
|
||||
const errors: Array<{ phase: string; message: string }> = []
|
||||
|
||||
const result = startHermesRuntimeBestEffort({
|
||||
configureRuntime: () => {
|
||||
throw new Error('unsupported')
|
||||
},
|
||||
onError: (phase, error) => {
|
||||
errors.push({
|
||||
phase,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(errors).toEqual([{ phase: 'configure', message: 'unsupported' }])
|
||||
})
|
||||
|
||||
it('reports install and start failures without throwing', async () => {
|
||||
const errors: Array<{ phase: string; message: string }> = []
|
||||
const runtime = {
|
||||
executeAction: async (action: RuntimeAction) => {
|
||||
throw new Error(`${action.type} failed`)
|
||||
},
|
||||
} as HermesContainerRuntime
|
||||
|
||||
const result = startHermesRuntimeBestEffort({
|
||||
configureRuntime: () => runtime,
|
||||
onError: (phase, error) => {
|
||||
errors.push({
|
||||
phase,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toBe(runtime)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(errors).toEqual([
|
||||
{ phase: 'install', message: 'install failed' },
|
||||
{ phase: 'start', message: 'start failed' },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,10 +48,12 @@ class TestContainer extends ManagedContainer {
|
||||
defaultImage: 'docker.io/test:latest',
|
||||
containerName: 'test-container',
|
||||
platforms: ['darwin' as NodeJS.Platform],
|
||||
readinessProbe: { timeoutMs: 20, intervalMs: 1 },
|
||||
}
|
||||
|
||||
probeOutcome: boolean | Error = true
|
||||
probeCalls = 0
|
||||
falseProbeCallsBeforeSuccess = 0
|
||||
|
||||
protected mountRoots(): readonly MountRoot[] {
|
||||
return [
|
||||
@@ -73,6 +75,10 @@ class TestContainer extends ManagedContainer {
|
||||
|
||||
protected async readinessProbe(): Promise<boolean> {
|
||||
this.probeCalls += 1
|
||||
if (this.falseProbeCallsBeforeSuccess > 0) {
|
||||
this.falseProbeCallsBeforeSuccess -= 1
|
||||
return false
|
||||
}
|
||||
if (this.probeOutcome instanceof Error) throw this.probeOutcome
|
||||
return this.probeOutcome
|
||||
}
|
||||
@@ -169,6 +175,18 @@ describe('ManagedContainer', () => {
|
||||
expect(c.getStatusSnapshot().lastError).toMatch(/probe failed/i)
|
||||
})
|
||||
|
||||
it('retries readiness probe before landing in running', async () => {
|
||||
const lockDir = mkTempDir()
|
||||
const deps = makeFakeDeps({ lockDir })
|
||||
const c = new TestContainer(deps)
|
||||
c.falseProbeCallsBeforeSuccess = 2
|
||||
|
||||
await c.start()
|
||||
|
||||
expect(c.getState()).toBe('running')
|
||||
expect(c.probeCalls).toBe(3)
|
||||
})
|
||||
|
||||
it('stop() force-transitions to stopped even from errored', async () => {
|
||||
const lockDir = mkTempDir()
|
||||
const deps = makeFakeDeps({ lockDir })
|
||||
|
||||
@@ -62,6 +62,39 @@ describe('VmRuntime', () => {
|
||||
).resolves.toContain('mountPoint: "/mnt/browseros/vm"')
|
||||
})
|
||||
|
||||
it('serializes concurrent ensureReady calls against the same Lima home', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ list: { stdout: '' }, create: {}, start: {} },
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareReadySsh(limaHome, logPath)
|
||||
const runtimeDeps = {
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
}
|
||||
const runtimeA = new VmRuntime(runtimeDeps)
|
||||
const runtimeB = new VmRuntime(runtimeDeps)
|
||||
const runtimeC = new VmRuntime(runtimeDeps)
|
||||
|
||||
await Promise.all([
|
||||
runtimeA.ensureReady(),
|
||||
runtimeB.ensureReady(),
|
||||
runtimeC.ensureReady(),
|
||||
])
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
expect(log.match(/ARGS:list --format json/g)).toHaveLength(1)
|
||||
expect(
|
||||
log.match(new RegExp(`ARGS:create --tty=false --name=${VM_NAME}`, 'g')),
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
log.match(new RegExp(`ARGS:start --tty=false ${VM_NAME}`, 'g')),
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns fast when the VM is already running', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
|
||||
@@ -90,6 +90,28 @@ describe('Application.start', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('starts Claude, Codex, and Hermes runtimes without blocking HTTP startup', async () => {
|
||||
const { Application, createHttpServer, runtimeActions } =
|
||||
await setupApplicationTest()
|
||||
const app = new Application(config)
|
||||
|
||||
await app.start()
|
||||
|
||||
expect(createHttpServer).toHaveBeenCalledTimes(1)
|
||||
expect(runtimeActions.claude).toEqual([
|
||||
{ type: 'install' },
|
||||
{ type: 'start' },
|
||||
])
|
||||
expect(runtimeActions.codex).toEqual([
|
||||
{ type: 'install' },
|
||||
{ type: 'start' },
|
||||
])
|
||||
expect(runtimeActions.hermes).toEqual([
|
||||
{ type: 'install' },
|
||||
{ type: 'start' },
|
||||
])
|
||||
})
|
||||
|
||||
it('stores the database below the BrowserOS directory instead of the execution directory', async () => {
|
||||
const originalBrowserosDir = process.env.BROWSEROS_DIR
|
||||
process.env.BROWSEROS_DIR = '/tmp/browseros-dogfood'
|
||||
@@ -207,8 +229,32 @@ async function setupApplicationTest() {
|
||||
}) as never,
|
||||
)
|
||||
|
||||
const hermesExecuteAction = mock(async () => {})
|
||||
const fakeHermesRuntime = { executeAction: hermesExecuteAction } as never
|
||||
const runtimeActions = {
|
||||
hermes: [] as Array<{ type: string }>,
|
||||
claude: [] as Array<{ type: string }>,
|
||||
codex: [] as Array<{ type: string }>,
|
||||
}
|
||||
const hermesExecuteAction = mock(async (action: { type: string }) => {
|
||||
runtimeActions.hermes.push(action)
|
||||
})
|
||||
const fakeHermesRuntime = {
|
||||
descriptor: { displayName: 'Hermes' },
|
||||
executeAction: hermesExecuteAction,
|
||||
} as never
|
||||
const claudeExecuteAction = mock(async (action: { type: string }) => {
|
||||
runtimeActions.claude.push(action)
|
||||
})
|
||||
const fakeClaudeRuntime = {
|
||||
descriptor: { displayName: 'Claude Code' },
|
||||
executeAction: claudeExecuteAction,
|
||||
} as never
|
||||
const codexExecuteAction = mock(async (action: { type: string }) => {
|
||||
runtimeActions.codex.push(action)
|
||||
})
|
||||
const fakeCodexRuntime = {
|
||||
descriptor: { displayName: 'Codex' },
|
||||
executeAction: codexExecuteAction,
|
||||
} as never
|
||||
spyOn(runtimeModule, 'configureHermesRuntime').mockImplementation(
|
||||
() => fakeHermesRuntime,
|
||||
)
|
||||
@@ -216,10 +262,16 @@ async function setupApplicationTest() {
|
||||
() => fakeHermesRuntime,
|
||||
)
|
||||
spyOn(runtimeModule, 'configureClaudeRuntime').mockImplementation(
|
||||
() => ({}) as never,
|
||||
() => fakeClaudeRuntime,
|
||||
)
|
||||
spyOn(runtimeModule, 'configureCodexRuntime').mockImplementation(
|
||||
() => ({}) as never,
|
||||
() => fakeCodexRuntime,
|
||||
)
|
||||
spyOn(runtimeModule, 'getClaudeRuntime').mockImplementation(
|
||||
() => fakeClaudeRuntime,
|
||||
)
|
||||
spyOn(runtimeModule, 'getCodexRuntime').mockImplementation(
|
||||
() => fakeCodexRuntime,
|
||||
)
|
||||
|
||||
const { Application } = await import('../src/main')
|
||||
@@ -234,5 +286,8 @@ async function setupApplicationTest() {
|
||||
initializeDb,
|
||||
openClawService: { prewarm, tryAutoStart },
|
||||
hermesService: { executeAction: hermesExecuteAction },
|
||||
claudeService: { executeAction: claudeExecuteAction },
|
||||
codexService: { executeAction: codexExecuteAction },
|
||||
runtimeActions,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,14 @@
|
||||
"types": "./src/constants/hermes.ts",
|
||||
"default": "./src/constants/hermes.ts"
|
||||
},
|
||||
"./constants/claude": {
|
||||
"types": "./src/constants/claude.ts",
|
||||
"default": "./src/constants/claude.ts"
|
||||
},
|
||||
"./constants/codex": {
|
||||
"types": "./src/constants/codex.ts",
|
||||
"default": "./src/constants/codex.ts"
|
||||
},
|
||||
"./constants/tool-approval": {
|
||||
"types": "./src/constants/tool-approval.ts",
|
||||
"default": "./src/constants/tool-approval.ts"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const CLAUDE_AGENT_NAME = 'claude'
|
||||
export const CLAUDE_IMAGE = 'docker.io/library/node:20-bookworm-slim'
|
||||
export const CLAUDE_COMPOSE_PROJECT_NAME = 'browseros-claude'
|
||||
export const CLAUDE_CONTAINER_NAME = `${CLAUDE_COMPOSE_PROJECT_NAME}-claude-agent-1`
|
||||
@@ -0,0 +1,4 @@
|
||||
export const CODEX_AGENT_NAME = 'codex'
|
||||
export const CODEX_IMAGE = 'docker.io/library/node:20-bookworm-slim'
|
||||
export const CODEX_COMPOSE_PROJECT_NAME = 'browseros-codex'
|
||||
export const CODEX_CONTAINER_NAME = `${CODEX_COMPOSE_PROJECT_NAME}-codex-agent-1`
|
||||
Reference in New Issue
Block a user