feat(agent): run claude and codex in VM containers

This commit is contained in:
shivammittal274
2026-05-12 02:44:07 +05:30
parent dad2331448
commit 985cdd319a
43 changed files with 2292 additions and 674 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,5 +47,6 @@ export interface AgentRowData {
export interface AgentRowCallbacks {
onDelete: (agent: AgentListItem) => void
onOpenTerminal?: (agent: AgentListItem) => void
onPinToggle: (agent: AgentListItem, next: boolean) => void
}

View File

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

View File

@@ -70,6 +70,7 @@ export function useAgentAdapters(enabled = true) {
return data.adapters ?? []
},
enabled: Boolean(baseUrl) && !urlLoading && enabled,
refetchInterval: enabled ? 5_000 : false,
})
return {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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