mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
7 Commits
feat/click
...
chore/chec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cba38ab81a | ||
|
|
abfc32b0c1 | ||
|
|
7a2a8e09bc | ||
|
|
6f8da5b7fb | ||
|
|
50cbe48558 | ||
|
|
d81b99c8e3 | ||
|
|
86cb03a1fc |
@@ -14,7 +14,7 @@ import { cn } from '@/lib/utils'
|
||||
interface ConversationHeaderProps {
|
||||
agent: HarnessAgent | null
|
||||
fallbackName: string
|
||||
fallbackAdapter: 'claude' | 'codex' | 'openclaw' | 'unknown'
|
||||
fallbackAdapter: 'claude' | 'codex' | 'openclaw' | 'hermes' | 'unknown'
|
||||
adapterHealth: AgentAdapterHealth | null
|
||||
backLabel: string
|
||||
backTarget: 'home' | 'page'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bot, Cpu, Sparkles } from 'lucide-react'
|
||||
import { Bot, Cpu, Sparkles, Wand2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import type { HarnessAgentAdapter } from './agent-harness-types'
|
||||
|
||||
@@ -23,6 +23,9 @@ export const AdapterIcon: FC<AdapterIconProps> = ({ adapter, className }) => {
|
||||
case 'openclaw':
|
||||
// OpenClaw — bot/automation framing.
|
||||
return <Bot className={className} aria-label="OpenClaw" />
|
||||
case 'hermes':
|
||||
// Hermes — messenger god framing, wand evokes the agentic conjuring.
|
||||
return <Wand2 className={className} aria-label="Hermes" />
|
||||
default:
|
||||
return <Bot className={className} aria-label="Agent" />
|
||||
}
|
||||
@@ -36,6 +39,8 @@ export function adapterLabel(adapter: HarnessAgentAdapter | 'unknown'): string {
|
||||
return 'Codex'
|
||||
case 'openclaw':
|
||||
return 'OpenClaw'
|
||||
case 'hermes':
|
||||
return 'Hermes'
|
||||
default:
|
||||
return 'Agent'
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ function inferAdapterFromLabel(label: string): HarnessAgentAdapter | 'unknown' {
|
||||
if (lower === 'claude code') return 'claude'
|
||||
if (lower === 'codex') return 'codex'
|
||||
if (lower === 'openclaw') return 'openclaw'
|
||||
if (lower === 'hermes') return 'hermes'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createAgentPageActions } from './agents-page-actions'
|
||||
import {
|
||||
useDefaultAgentName,
|
||||
useHarnessAgentDefaults,
|
||||
useHermesProviderSelection,
|
||||
useOpenClawProviderSelection,
|
||||
} from './agents-page-hooks'
|
||||
import {
|
||||
@@ -106,6 +107,7 @@ export const AgentsPage: FC = () => {
|
||||
)
|
||||
const [harnessModelId, setHarnessModelId] = useState('')
|
||||
const [harnessReasoningEffort, setHarnessReasoningEffort] = useState('')
|
||||
const [createHermesProviderId, setCreateHermesProviderId] = useState('')
|
||||
const [showTerminal, setShowTerminal] = useState(false)
|
||||
const [cliAuthModalOpen, setCliAuthModalOpen] = useState(false)
|
||||
const [pageError, setPageError] = useState<string | null>(null)
|
||||
@@ -133,6 +135,14 @@ export const AgentsPage: FC = () => {
|
||||
cliAuthModalOpen,
|
||||
setCliAuthModalOpen,
|
||||
})
|
||||
const { selectableHermesProviders } = useHermesProviderSelection({
|
||||
providers,
|
||||
defaultProviderId,
|
||||
createOpen,
|
||||
createRuntime,
|
||||
createHermesProviderId,
|
||||
setCreateHermesProviderId,
|
||||
})
|
||||
useDefaultAgentName(createOpen, setNewName)
|
||||
useHarnessAgentDefaults({
|
||||
adapters,
|
||||
@@ -226,11 +236,13 @@ export const AgentsPage: FC = () => {
|
||||
createAgentPageActions({
|
||||
createProviderId,
|
||||
createRuntime,
|
||||
createHermesProviderId,
|
||||
harnessModelId,
|
||||
harnessReasoningEffort,
|
||||
navigate,
|
||||
newName,
|
||||
selectableOpenClawProviders,
|
||||
selectableHermesProviders,
|
||||
setupProviderId,
|
||||
createHarnessAgent: createHarnessAgent.mutateAsync,
|
||||
createOpenClawAgent,
|
||||
@@ -386,6 +398,8 @@ export const AgentsPage: FC = () => {
|
||||
harnessAdapterId={harnessAdapterId}
|
||||
harnessModelId={harnessModelId}
|
||||
harnessReasoningEffort={harnessReasoningEffort}
|
||||
hermesProviders={selectableHermesProviders}
|
||||
hermesSelectedProviderId={createHermesProviderId}
|
||||
name={newName}
|
||||
open={createOpen}
|
||||
providers={selectableOpenClawProviders}
|
||||
@@ -401,12 +415,14 @@ export const AgentsPage: FC = () => {
|
||||
if (!open) {
|
||||
setCreateError(null)
|
||||
createHarnessAgent.reset()
|
||||
setCreateHermesProviderId('')
|
||||
}
|
||||
}}
|
||||
onRuntimeChange={setCreateRuntime}
|
||||
onHarnessAdapterChange={handleHarnessAdapterChange}
|
||||
onHarnessModelChange={setHarnessModelId}
|
||||
onHarnessReasoningChange={setHarnessReasoningEffort}
|
||||
onHermesProviderChange={setCreateHermesProviderId}
|
||||
onNameChange={setNewName}
|
||||
onProviderChange={setCreateProviderId}
|
||||
/>
|
||||
|
||||
@@ -40,6 +40,8 @@ interface NewAgentDialogProps {
|
||||
harnessAdapterId: HarnessAgentAdapter
|
||||
harnessModelId: string
|
||||
harnessReasoningEffort: string
|
||||
hermesProviders: ProviderOption[]
|
||||
hermesSelectedProviderId: string
|
||||
name: string
|
||||
open: boolean
|
||||
providers: ProviderOption[]
|
||||
@@ -55,6 +57,7 @@ interface NewAgentDialogProps {
|
||||
onHarnessAdapterChange: (adapter: HarnessAgentAdapter) => void
|
||||
onHarnessModelChange: (modelId: string) => void
|
||||
onHarnessReasoningChange: (reasoningEffort: string) => void
|
||||
onHermesProviderChange: (providerId: string) => void
|
||||
onNameChange: (name: string) => void
|
||||
onProviderChange: (providerId: string) => void
|
||||
}
|
||||
@@ -69,6 +72,8 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
harnessAdapterId,
|
||||
harnessModelId,
|
||||
harnessReasoningEffort,
|
||||
hermesProviders,
|
||||
hermesSelectedProviderId,
|
||||
name,
|
||||
open,
|
||||
providers,
|
||||
@@ -84,22 +89,29 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
onHarnessAdapterChange,
|
||||
onHarnessModelChange,
|
||||
onHarnessReasoningChange,
|
||||
onHermesProviderChange,
|
||||
onNameChange,
|
||||
onProviderChange,
|
||||
}) => {
|
||||
const selectedHarnessAdapter =
|
||||
adapters.find((adapter) => adapter.id === harnessAdapterId) ?? adapters[0]
|
||||
const isHarnessRuntime = createRuntime !== 'openclaw'
|
||||
const isHermesRuntime = createRuntime === 'hermes'
|
||||
const isClassicHarnessRuntime = isHarnessRuntime && !isHermesRuntime
|
||||
const openClawBlocked = createRuntime === 'openclaw' && !canManageOpenClaw
|
||||
const cliBlocked =
|
||||
createRuntime === 'openclaw' &&
|
||||
!!selectedCliProvider &&
|
||||
!cliAuthStatus?.loggedIn
|
||||
const hermesBlocked =
|
||||
isHermesRuntime &&
|
||||
(hermesProviders.length === 0 || !hermesSelectedProviderId)
|
||||
const canCreate =
|
||||
Boolean(name.trim()) &&
|
||||
!creating &&
|
||||
!openClawBlocked &&
|
||||
!cliBlocked &&
|
||||
!hermesBlocked &&
|
||||
(createRuntime === 'openclaw'
|
||||
? providers.length > 0
|
||||
: Boolean(selectedHarnessAdapter))
|
||||
@@ -143,7 +155,8 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
if (
|
||||
value === 'openclaw' ||
|
||||
value === 'claude' ||
|
||||
value === 'codex'
|
||||
value === 'codex' ||
|
||||
value === 'hermes'
|
||||
) {
|
||||
onRuntimeChange(value)
|
||||
if (value !== 'openclaw') onHarnessAdapterChange(value)
|
||||
@@ -196,7 +209,16 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isHarnessRuntime ? (
|
||||
{isHermesRuntime ? (
|
||||
<ProviderSelector
|
||||
providers={hermesProviders}
|
||||
defaultProviderId={defaultProviderId}
|
||||
selectedId={hermesSelectedProviderId}
|
||||
onSelect={onHermesProviderChange}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isClassicHarnessRuntime ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="harness-model">Model</Label>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentEntry } from './useOpenClaw'
|
||||
|
||||
export type HarnessAgentAdapter = 'claude' | 'codex' | 'openclaw'
|
||||
export type HarnessAgentAdapter = 'claude' | 'codex' | 'openclaw' | 'hermes'
|
||||
|
||||
/**
|
||||
* One file the harness attributed to the assistant turn that just
|
||||
@@ -130,6 +130,17 @@ export interface CreateHarnessAgentInput {
|
||||
adapter: HarnessAgentAdapter
|
||||
modelId?: string
|
||||
reasoningEffort?: string
|
||||
/**
|
||||
* Hermes-only — provider id from `HERMES_SUPPORTED_PROVIDERS`. When
|
||||
* paired with `apiKey`, the backend writes a per-agent
|
||||
* config.yaml + .env into the agent's HERMES_HOME so the first chat
|
||||
* doesn't depend on the user having run `hermes setup` globally.
|
||||
*/
|
||||
providerType?: string
|
||||
/** Hermes-only — API key paired with `providerType`. */
|
||||
apiKey?: string
|
||||
/** Hermes-only — base URL for the `custom` provider. */
|
||||
baseUrl?: string
|
||||
}
|
||||
|
||||
export interface HarnessHistoryReasoning {
|
||||
|
||||
@@ -20,17 +20,22 @@ import type {
|
||||
export interface AgentPageActionInput {
|
||||
createProviderId: string
|
||||
createRuntime: CreateAgentRuntime
|
||||
createHermesProviderId: string
|
||||
harnessModelId: string
|
||||
harnessReasoningEffort: string
|
||||
navigate: NavigateFunction
|
||||
newName: string
|
||||
selectableOpenClawProviders: ProviderOption[]
|
||||
selectableHermesProviders: ProviderOption[]
|
||||
setupProviderId: string
|
||||
createHarnessAgent: (input: {
|
||||
name: string
|
||||
adapter: HarnessAgentAdapter
|
||||
modelId?: string
|
||||
reasoningEffort?: string
|
||||
providerType?: string
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
}) => Promise<HarnessAgent>
|
||||
createOpenClawAgent: (
|
||||
input: OpenClawAgentMutationInput,
|
||||
@@ -114,20 +119,37 @@ export function createAgentPageActions(input: AgentPageActionInput) {
|
||||
const handleHarnessCreate = async () => {
|
||||
if (!input.newName.trim()) return
|
||||
|
||||
const isHermes = input.createRuntime === 'hermes'
|
||||
// Hermes pulls every provider field from the user's selected entry
|
||||
// in the global LLM-providers list (managed under AI Settings). The
|
||||
// backend rejects creation if any required field is missing.
|
||||
const hermesProvider = isHermes
|
||||
? input.selectableHermesProviders.find(
|
||||
(option) => option.id === input.createHermesProviderId,
|
||||
)
|
||||
: undefined
|
||||
const effectiveModelId = isHermes
|
||||
? hermesProvider?.modelId
|
||||
: input.harnessModelId || undefined
|
||||
|
||||
input.setCreateError(null)
|
||||
try {
|
||||
const agent = await input.createHarnessAgent({
|
||||
name: input.newName.trim(),
|
||||
adapter: input.createRuntime as HarnessAgentAdapter,
|
||||
modelId: input.harnessModelId || undefined,
|
||||
modelId: effectiveModelId,
|
||||
reasoningEffort: input.harnessReasoningEffort || undefined,
|
||||
providerType: hermesProvider?.type,
|
||||
apiKey: hermesProvider?.apiKey,
|
||||
baseUrl: hermesProvider?.baseUrl,
|
||||
})
|
||||
input.setCreateOpen(false)
|
||||
input.setNewName('')
|
||||
track(AGENT_CREATED_EVENT, {
|
||||
runtime: input.createRuntime,
|
||||
model_id: input.harnessModelId || undefined,
|
||||
model_id: effectiveModelId,
|
||||
reasoning_effort: input.harnessReasoningEffort || undefined,
|
||||
provider_type: hermesProvider?.type,
|
||||
})
|
||||
input.navigate(`/agents/${agent.id}`)
|
||||
} catch (err) {
|
||||
@@ -140,6 +162,7 @@ export function createAgentPageActions(input: AgentPageActionInput) {
|
||||
openclaw: handleOpenClawCreate,
|
||||
claude: handleHarnessCreate,
|
||||
codex: handleHarnessCreate,
|
||||
hermes: handleHarnessCreate,
|
||||
}
|
||||
void createByRuntime[input.createRuntime]()
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import type {
|
||||
HarnessAdapterDescriptor,
|
||||
HarnessAgentAdapter,
|
||||
} from './agent-harness-types'
|
||||
import type { CreateAgentRuntime } from './agents-page-types'
|
||||
import type { CreateAgentRuntime, ProviderOption } from './agents-page-types'
|
||||
import { toProviderOptions } from './agents-page-utils'
|
||||
import { getHermesSupportedProviders } from './hermes-supported-providers'
|
||||
import {
|
||||
buildOpenClawCliProviderOptions,
|
||||
findOpenClawCliProviderById,
|
||||
@@ -171,3 +172,60 @@ export function useOpenClawProviderSelection(input: {
|
||||
cliAuthError,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of useOpenClawProviderSelection but for Hermes. Hermes only
|
||||
* needs the create-dialog flow (no setup dialog, no CLI providers), so
|
||||
* this hook is much smaller — it just filters the global provider list
|
||||
* to ones Hermes can drive and seeds the selected id when the dialog
|
||||
* opens.
|
||||
*/
|
||||
export function useHermesProviderSelection(input: {
|
||||
providers: LlmProviderConfig[]
|
||||
defaultProviderId: string
|
||||
createOpen: boolean
|
||||
createRuntime: CreateAgentRuntime
|
||||
createHermesProviderId: string
|
||||
setCreateHermesProviderId: Dispatch<SetStateAction<string>>
|
||||
}) {
|
||||
const {
|
||||
providers,
|
||||
defaultProviderId,
|
||||
createOpen,
|
||||
createRuntime,
|
||||
createHermesProviderId,
|
||||
setCreateHermesProviderId,
|
||||
} = input
|
||||
|
||||
const selectableHermesProviders = useMemo<ProviderOption[]>(
|
||||
() =>
|
||||
getHermesSupportedProviders(providers).map((provider) => ({
|
||||
id: provider.id,
|
||||
type: provider.type,
|
||||
name: provider.name,
|
||||
modelId: provider.modelId,
|
||||
baseUrl: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
})),
|
||||
[providers],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectableHermesProviders.length === 0) return
|
||||
if (!createOpen || createRuntime !== 'hermes') return
|
||||
if (createHermesProviderId) return
|
||||
const fallbackId =
|
||||
selectableHermesProviders.find((p) => p.id === defaultProviderId)?.id ??
|
||||
selectableHermesProviders[0].id
|
||||
setCreateHermesProviderId(fallbackId)
|
||||
}, [
|
||||
createHermesProviderId,
|
||||
createOpen,
|
||||
createRuntime,
|
||||
defaultProviderId,
|
||||
selectableHermesProviders,
|
||||
setCreateHermesProviderId,
|
||||
])
|
||||
|
||||
return { selectableHermesProviders }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES,
|
||||
type HermesSupportedBrowserosProviderType,
|
||||
} from '@browseros/shared/constants/hermes'
|
||||
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
export function isHermesSupportedProviderType(
|
||||
providerType: ProviderType,
|
||||
): providerType is HermesSupportedBrowserosProviderType {
|
||||
return (
|
||||
HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES as readonly ProviderType[]
|
||||
).includes(providerType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the user's global LLM providers down to ones Hermes can use.
|
||||
* A provider qualifies when its type is in the Hermes-supported set
|
||||
* AND it has an API key wired up. CLI-style providers (chatgpt-pro,
|
||||
* github-copilot, qwen-code) and other unsupported types (browseros,
|
||||
* ollama, lmstudio, bedrock, azure, google, moonshot) are filtered
|
||||
* out — Hermes can't drive them today.
|
||||
*/
|
||||
export function getHermesSupportedProviders(
|
||||
providers: LlmProviderConfig[],
|
||||
): LlmProviderConfig[] {
|
||||
return providers.filter(
|
||||
(provider) =>
|
||||
!!provider.apiKey && isHermesSupportedProviderType(provider.type),
|
||||
)
|
||||
}
|
||||
@@ -85,7 +85,8 @@ export const SidebarLayout: FC = () => {
|
||||
|
||||
return (
|
||||
<RpcClientProvider>
|
||||
<div className="relative min-h-screen bg-background">
|
||||
{/* pl-14 offsets all content by the collapsed sidebar width (w-14 = 56px) so it never sits under the rail */}
|
||||
<div className="relative min-h-screen bg-background pl-14">
|
||||
{/* Sidebar - fixed overlay */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: hover interactions needed */}
|
||||
<div
|
||||
@@ -96,7 +97,6 @@ export const SidebarLayout: FC = () => {
|
||||
<AppSidebar expanded={sidebarOpen} onOpenShortcuts={openShortcuts} />
|
||||
</div>
|
||||
|
||||
{/* Main content - full width, centered */}
|
||||
{location.pathname === '/home/chat' ? (
|
||||
<main className="relative h-dvh overflow-hidden">
|
||||
<Outlet />
|
||||
|
||||
@@ -108,6 +108,7 @@ function formatAdapterName(adapter: HarnessAgentAdapter): string {
|
||||
if (adapter === 'claude') return 'Claude Code'
|
||||
if (adapter === 'codex') return 'Codex'
|
||||
if (adapter === 'openclaw') return 'OpenClaw'
|
||||
if (adapter === 'hermes') return 'Hermes'
|
||||
return adapter
|
||||
}
|
||||
|
||||
|
||||
@@ -503,7 +503,7 @@ async function scenarioConfig(): Promise<void> {
|
||||
await tearDown(s)
|
||||
return
|
||||
}
|
||||
const newValue = target.options![0].value
|
||||
const newValue = target.options?.[0].value
|
||||
console.log(`[config] setting configId=${target.id} value=${newValue}`)
|
||||
try {
|
||||
// @ts-expect-error - input shape varies
|
||||
|
||||
@@ -14,7 +14,10 @@ import { stream } from 'hono/streaming'
|
||||
import { formatUserMessage } from '../../agent/format-message'
|
||||
import type { Browser } from '../../browser/browser'
|
||||
import { createAcpUIMessageStreamResponse } from '../../lib/agents/acp-ui-message-stream'
|
||||
import type { OpenclawGatewayAccessor } from '../../lib/agents/acpx-runtime'
|
||||
import type {
|
||||
HermesGatewayAccessor,
|
||||
OpenclawGatewayAccessor,
|
||||
} from '../../lib/agents/acpx-runtime'
|
||||
import type {
|
||||
ActiveTurnInfo,
|
||||
TurnFrame,
|
||||
@@ -35,6 +38,7 @@ import {
|
||||
type AgentDefinitionWithActivity,
|
||||
AgentHarnessService,
|
||||
type GatewayStatusSnapshot,
|
||||
HermesProviderConfigInvalidError,
|
||||
InvalidAgentUpdateError,
|
||||
MessageQueueFullError,
|
||||
type OpenClawProvisioner,
|
||||
@@ -46,7 +50,6 @@ import {
|
||||
UnknownAgentError,
|
||||
} from '../services/agents/agent-harness-service'
|
||||
import type { FilePreview } from '../services/openclaw/file-preview'
|
||||
import type { OpenClawGatewayChatClient } from '../services/openclaw/openclaw-gateway-chat-client'
|
||||
import type { Env } from '../types'
|
||||
import { resolveBrowserContextPageIds } from '../utils/resolve-browser-context-page-ids'
|
||||
|
||||
@@ -129,18 +132,26 @@ type AgentRouteDeps = {
|
||||
openclawGateway?: OpenclawGatewayAccessor
|
||||
/**
|
||||
* Optional. Enables the image-attachment carve-out for OpenClaw
|
||||
* agents — image-bearing turns route through the gateway HTTP
|
||||
* `/v1/chat/completions` instead of the ACP bridge (which drops
|
||||
* image content blocks).
|
||||
*/
|
||||
openclawGatewayChat?: OpenClawGatewayChatClient
|
||||
/**
|
||||
* Required to dual-create/delete `openclaw` adapter agents on the
|
||||
* gateway side. Without this, openclaw create requests fail with 503.
|
||||
*/
|
||||
openclawProvisioner?: OpenClawProvisioner
|
||||
/**
|
||||
* Required when a `hermes` adapter agent is in use; harmless when
|
||||
* absent (the AcpxRuntime falls back to a host-process spawn for
|
||||
* tests / dev). Forwarded to the AcpxRuntime so it can spawn
|
||||
* `hermes acp` inside the Hermes container.
|
||||
*/
|
||||
hermesGateway?: HermesGatewayAccessor
|
||||
/** Optional override; defaults to a fresh in-memory checker. */
|
||||
adapterHealth?: AdapterHealthChecker
|
||||
/**
|
||||
* Optional listener attached to the constructed harness. Receives
|
||||
* turn lifecycle events for every running agent. Wired by the server
|
||||
* to feed OpenClaw's ClawSession dashboard from the same stream the
|
||||
* chat panel sees, so no second WS observer is needed.
|
||||
*/
|
||||
onTurnLifecycle?: import('../services/agents/agent-harness-service').TurnLifecycleListener
|
||||
}
|
||||
|
||||
type SidepanelAgentChatRequest = {
|
||||
@@ -159,9 +170,12 @@ export function createAgentRoutes(deps: AgentRouteDeps = {}) {
|
||||
new AgentHarnessService({
|
||||
browserosServerPort: deps.browserosServerPort,
|
||||
openclawGateway: deps.openclawGateway,
|
||||
openclawGatewayChat: deps.openclawGatewayChat,
|
||||
openclawProvisioner: deps.openclawProvisioner,
|
||||
hermesGateway: deps.hermesGateway,
|
||||
})
|
||||
if (deps.onTurnLifecycle && service instanceof AgentHarnessService) {
|
||||
service.onTurnLifecycle(deps.onTurnLifecycle)
|
||||
}
|
||||
// One checker per route mount. Cached probes refresh every 5min;
|
||||
// tests can swap in an alternate via deps if needed.
|
||||
const adapterHealth = deps.adapterHealth ?? new AdapterHealthChecker()
|
||||
@@ -672,11 +686,14 @@ async function parseCreateAgentBody(c: Context<Env>): Promise<
|
||||
? record.reasoningEffort.trim()
|
||||
: undefined
|
||||
|
||||
// OpenClaw agents resolve their model from the gateway-side provider
|
||||
// config rather than from the harness catalog. Skip catalog model
|
||||
// validation for that adapter; everything else still uses the catalog.
|
||||
// OpenClaw and Hermes resolve their model from per-agent provider
|
||||
// config (gateway / config.yaml) rather than from the harness catalog.
|
||||
// Skip catalog model validation for those adapters — both have an
|
||||
// empty `models: []` in the catalog by design — everything else still
|
||||
// uses the catalog for validation.
|
||||
if (
|
||||
record.adapter !== 'openclaw' &&
|
||||
record.adapter !== 'hermes' &&
|
||||
!isSupportedAgentModel(record.adapter, modelId)
|
||||
) {
|
||||
return { error: 'Invalid modelId' }
|
||||
@@ -913,6 +930,9 @@ function handleAgentRouteError(c: Context<Env>, err: unknown) {
|
||||
if (err instanceof InvalidAgentUpdateError) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
if (err instanceof HermesProviderConfigInvalidError) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
if (err instanceof MessageQueueFullError) {
|
||||
return c.json({ error: err.message }, 429)
|
||||
}
|
||||
|
||||
@@ -42,12 +42,12 @@ import { createSoulRoutes } from './routes/soul'
|
||||
import { createStatusRoute } from './routes/status'
|
||||
import { createTerminalRoutes } from './routes/terminal'
|
||||
import { GlobalAclPolicyService } from './services/acl/global-acl-policy'
|
||||
import { getHermesContainerService } from './services/hermes/hermes-container'
|
||||
import {
|
||||
connectKlavisInBackground,
|
||||
type KlavisProxyRef,
|
||||
} from './services/klavis/strata-proxy'
|
||||
import { convertOpenClawHistoryToAgentHistory } from './services/openclaw/history-mapper'
|
||||
import { OpenClawGatewayChatClient } from './services/openclaw/openclaw-gateway-chat-client'
|
||||
import { getOpenClawService } from './services/openclaw/openclaw-service'
|
||||
import type { Env, HttpServerConfig } from './types'
|
||||
import { defaultCorsConfig } from './utils/cors'
|
||||
@@ -138,16 +138,11 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
browserosServerPort: port,
|
||||
browser,
|
||||
openclawGateway: {
|
||||
getGatewayToken: () => getOpenClawService().getGatewayToken(),
|
||||
getContainerName: () => OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
getLimaHomeDir: () => getLimaHomeDir(),
|
||||
getLimactlPath: () => resolveBundledLimactl(resourcesDir),
|
||||
getVmName: () => VM_NAME,
|
||||
},
|
||||
openclawGatewayChat: new OpenClawGatewayChatClient(
|
||||
() => getOpenClawService().getPort(),
|
||||
async () => getOpenClawService().getGatewayToken(),
|
||||
),
|
||||
openclawProvisioner: {
|
||||
createAgent: (input) => getOpenClawService().createAgent(input),
|
||||
removeAgent: (agentId) => getOpenClawService().removeAgent(agentId),
|
||||
@@ -170,6 +165,15 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
return convertOpenClawHistoryToAgentHistory(agentId, raw)
|
||||
},
|
||||
},
|
||||
onTurnLifecycle: (agent, event) => {
|
||||
if (agent.adapter !== 'openclaw') return
|
||||
getOpenClawService().recordAgentTurnEvent(
|
||||
agent.id,
|
||||
agent.sessionKey,
|
||||
event,
|
||||
)
|
||||
},
|
||||
hermesGateway: getHermesContainerService().getAccessor(),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import {
|
||||
AcpxRuntime,
|
||||
type HermesGatewayAccessor,
|
||||
type OpenclawGatewayAccessor,
|
||||
} from '../../../lib/agents/acpx-runtime'
|
||||
import {
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
type QueuedMessage,
|
||||
type QueuedMessageAttachment,
|
||||
} from '../../../lib/agents/message-queue'
|
||||
import { writeHermesPerAgentProvider } from '../hermes/hermes-paths'
|
||||
import { getHermesProviderMapping } from '../hermes/hermes-provider-map'
|
||||
|
||||
export {
|
||||
MessageQueueFullError,
|
||||
@@ -46,7 +49,6 @@ import {
|
||||
type FilePreview,
|
||||
} from '../openclaw/file-preview'
|
||||
import { getHostWorkspaceDir } from '../openclaw/openclaw-env'
|
||||
import type { OpenClawGatewayChatClient } from '../openclaw/openclaw-gateway-chat-client'
|
||||
import {
|
||||
type FileSnapshot,
|
||||
type ProducedFileRow,
|
||||
@@ -174,12 +176,39 @@ export interface GatewayStatusSnapshot {
|
||||
| null
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-turn event the harness emits to subscribers. Lets services that
|
||||
* want to track liveness for a specific adapter (e.g. OpenClaw's
|
||||
* ClawSession dashboard) react to the same stream the chat panel sees,
|
||||
* without each adapter spawning its own gateway-side observer.
|
||||
*/
|
||||
export type TurnLifecycleEvent =
|
||||
| { type: 'turn_started' }
|
||||
| { type: 'turn_event'; event: AgentStreamEvent }
|
||||
| { type: 'turn_ended'; error?: string }
|
||||
|
||||
export type TurnLifecycleListener = (
|
||||
agent: {
|
||||
id: string
|
||||
adapter: AgentDefinition['adapter']
|
||||
sessionKey: string
|
||||
},
|
||||
event: TurnLifecycleEvent,
|
||||
) => void
|
||||
|
||||
export class AgentHarnessService {
|
||||
private readonly agentStore: AgentStore
|
||||
private readonly runtime: AgentRuntime
|
||||
private readonly openclawProvisioner: OpenClawProvisioner | null
|
||||
private readonly turnRegistry: TurnRegistry
|
||||
private readonly messageQueue: FileMessageQueue
|
||||
private readonly turnLifecycleListeners = new Set<TurnLifecycleListener>()
|
||||
/**
|
||||
* Optional override for the BrowserOS dir used by Hermes per-agent
|
||||
* provider config writes. Defaults to the global `getBrowserosDir()`
|
||||
* lookup at write time when undefined; tests can inject a tmp dir.
|
||||
*/
|
||||
private readonly browserosDir: string | undefined
|
||||
/**
|
||||
* Lazy-initialised so tests that swap in a fake `agentStore` don't
|
||||
* eagerly hit `getDb()` (which throws when the test harness hasn't
|
||||
@@ -203,9 +232,10 @@ export class AgentHarnessService {
|
||||
agentStore?: AgentStore
|
||||
runtime?: AgentRuntime
|
||||
browserosServerPort?: number
|
||||
browserosDir?: string
|
||||
openclawGateway?: OpenclawGatewayAccessor
|
||||
openclawGatewayChat?: OpenClawGatewayChatClient
|
||||
openclawProvisioner?: OpenClawProvisioner
|
||||
hermesGateway?: HermesGatewayAccessor
|
||||
turnRegistry?: TurnRegistry
|
||||
messageQueue?: FileMessageQueue
|
||||
producedFilesStore?: ProducedFilesStore
|
||||
@@ -217,11 +247,12 @@ export class AgentHarnessService {
|
||||
new AcpxRuntime({
|
||||
browserosServerPort: deps.browserosServerPort,
|
||||
openclawGateway: deps.openclawGateway,
|
||||
openclawGatewayChat: deps.openclawGatewayChat,
|
||||
hermesGateway: deps.hermesGateway,
|
||||
})
|
||||
this.openclawProvisioner = deps.openclawProvisioner ?? null
|
||||
this.turnRegistry = deps.turnRegistry ?? new TurnRegistry()
|
||||
this.messageQueue = deps.messageQueue ?? new FileMessageQueue()
|
||||
this.browserosDir = deps.browserosDir
|
||||
if (deps.producedFilesStore) {
|
||||
this.explicitProducedFilesStore = deps.producedFilesStore
|
||||
}
|
||||
@@ -348,6 +379,39 @@ export class AgentHarnessService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to turn lifecycle events for every running agent. Returns
|
||||
* an unsubscribe function. Listeners are best-effort: a throwing
|
||||
* listener does not break the turn.
|
||||
*/
|
||||
onTurnLifecycle(listener: TurnLifecycleListener): () => void {
|
||||
this.turnLifecycleListeners.add(listener)
|
||||
return () => this.turnLifecycleListeners.delete(listener)
|
||||
}
|
||||
|
||||
private emitTurnLifecycle(
|
||||
agent: AgentDefinition,
|
||||
event: TurnLifecycleEvent,
|
||||
): void {
|
||||
if (this.turnLifecycleListeners.size === 0) return
|
||||
const summary = {
|
||||
id: agent.id,
|
||||
adapter: agent.adapter,
|
||||
sessionKey: agent.sessionKey,
|
||||
}
|
||||
for (const listener of this.turnLifecycleListeners) {
|
||||
try {
|
||||
listener(summary, event)
|
||||
} catch (err) {
|
||||
logger.warn('Turn lifecycle listener threw', {
|
||||
agentId: agent.id,
|
||||
eventType: event.type,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark `agentId` as actively running a turn. */
|
||||
notifyTurnStarted(agentId: string): void {
|
||||
this.activity.set(agentId, { status: 'working', lastEventAt: Date.now() })
|
||||
@@ -482,8 +546,24 @@ export class AgentHarnessService {
|
||||
}
|
||||
|
||||
async createAgent(input: CreateAgentInput): Promise<AgentDefinition> {
|
||||
if (input.adapter === 'hermes') {
|
||||
// Validate before touching the store so we don't leave an orphan
|
||||
// record on the unhappy path.
|
||||
assertHermesProviderInputValid(input)
|
||||
}
|
||||
|
||||
const agent = await this.agentStore.create(input)
|
||||
|
||||
if (agent.adapter === 'hermes') {
|
||||
try {
|
||||
await this.writeHermesPerAgentProvider(agent.id, input)
|
||||
} catch (err) {
|
||||
await this.agentStore.delete(agent.id).catch(() => {})
|
||||
throw err
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
if (agent.adapter !== 'openclaw') {
|
||||
return agent
|
||||
}
|
||||
@@ -524,6 +604,35 @@ export class AgentHarnessService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write Hermes' per-agent config.yaml + .env into the on-host home
|
||||
* dir. Caller must have already run assertHermesProviderInputValid;
|
||||
* any throw here is a real I/O failure and must roll back the agent
|
||||
* record.
|
||||
*/
|
||||
private async writeHermesPerAgentProvider(
|
||||
agentId: string,
|
||||
input: CreateAgentInput,
|
||||
): Promise<void> {
|
||||
// Non-null assertions are safe: assertHermesProviderInputValid ran
|
||||
// first and rejects when any required field is missing.
|
||||
const mapping = getHermesProviderMapping(input.providerType as string)
|
||||
if (!mapping) {
|
||||
throw new HermesProviderConfigInvalidError(
|
||||
`Provider type "${input.providerType}" is not supported by Hermes`,
|
||||
)
|
||||
}
|
||||
await writeHermesPerAgentProvider({
|
||||
browserosDir: this.browserosDir,
|
||||
agentId,
|
||||
providerId: mapping.hermesProvider,
|
||||
envVarName: mapping.envVarName,
|
||||
apiKey: (input.apiKey as string).trim(),
|
||||
modelId: (input.modelId as string).trim(),
|
||||
baseUrl: input.baseUrl?.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls every gateway-side OpenClaw agent into the harness store as a
|
||||
* harness record (idempotent, safe to call repeatedly). This lets
|
||||
@@ -764,6 +873,7 @@ export class AgentHarnessService {
|
||||
prompt: input.message,
|
||||
})
|
||||
this.notifyTurnStarted(agent.id)
|
||||
this.emitTurnLifecycle(agent, { type: 'turn_started' })
|
||||
|
||||
// Kick off the runtime call in the background. The per-turn
|
||||
// AbortController — NOT the HTTP request signal — is what cancels
|
||||
@@ -903,6 +1013,7 @@ export class AgentHarnessService {
|
||||
if (done) break
|
||||
if (value.type === 'error') lastErrorMessage = value.message
|
||||
this.turnRegistry.pushEvent(turnId, value)
|
||||
this.emitTurnLifecycle(agent, { type: 'turn_event', event: value })
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
@@ -964,6 +1075,10 @@ export class AgentHarnessService {
|
||||
ok: lastErrorMessage === undefined,
|
||||
error: lastErrorMessage,
|
||||
})
|
||||
this.emitTurnLifecycle(agent, {
|
||||
type: 'turn_ended',
|
||||
error: lastErrorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1130,6 +1245,48 @@ export class InvalidAgentUpdateError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a Hermes adapter agent is created without a complete
|
||||
* provider config (provider type, API key, model id; base URL when the
|
||||
* provider mapping requires it). Surfaces as a 400 in the route layer.
|
||||
*/
|
||||
export class HermesProviderConfigInvalidError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'HermesProviderConfigInvalidError'
|
||||
}
|
||||
}
|
||||
|
||||
function assertHermesProviderInputValid(input: CreateAgentInput): void {
|
||||
const providerType = input.providerType?.trim()
|
||||
if (!providerType) {
|
||||
throw new HermesProviderConfigInvalidError(
|
||||
'Hermes agent requires providerType (pick a provider configured in BrowserOS AI Settings)',
|
||||
)
|
||||
}
|
||||
const mapping = getHermesProviderMapping(providerType)
|
||||
if (!mapping) {
|
||||
throw new HermesProviderConfigInvalidError(
|
||||
`Provider type "${providerType}" is not supported by Hermes`,
|
||||
)
|
||||
}
|
||||
if (!input.apiKey?.trim()) {
|
||||
throw new HermesProviderConfigInvalidError(
|
||||
'Hermes agent requires apiKey from the selected provider',
|
||||
)
|
||||
}
|
||||
if (!input.modelId?.trim()) {
|
||||
throw new HermesProviderConfigInvalidError(
|
||||
'Hermes agent requires modelId from the selected provider',
|
||||
)
|
||||
}
|
||||
if (mapping.requiresBaseUrl && !input.baseUrl?.trim()) {
|
||||
throw new HermesProviderConfigInvalidError(
|
||||
`Provider type "${providerType}" requires baseUrl`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when `startTurn` is called for an agent that already has an
|
||||
* in-flight turn. The route layer maps this to 409 + the existing
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Lifecycle service for the Hermes ACPX adapter container. Hermes runs
|
||||
* in the same Lima VM as OpenClaw — image is pulled into containerd, an
|
||||
* idle container is kept up so the harness can `nerdctl exec hermes acp`
|
||||
* per turn. Much smaller than OpenClawService: no gateway, no token, no
|
||||
* agent CRUD via container — the harness owns all of that.
|
||||
*/
|
||||
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
HERMES_CONTAINER_HARNESS_DIR,
|
||||
HERMES_CONTAINER_NAME,
|
||||
HERMES_IMAGE,
|
||||
} from '@browseros/shared/constants/hermes'
|
||||
import { getBrowserosDir } from '../../../lib/browseros-dir'
|
||||
import {
|
||||
ContainerCli,
|
||||
type ContainerSpec,
|
||||
ImageLoader,
|
||||
} from '../../../lib/container'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import { withProcessLock } from '../../../lib/process-lock'
|
||||
import {
|
||||
GUEST_VM_STATE,
|
||||
getLimaHomeDir,
|
||||
resolveBundledLimactl,
|
||||
resolveBundledLimaTemplate,
|
||||
VM_NAME,
|
||||
VmRuntime,
|
||||
} from '../../../lib/vm'
|
||||
import { ContainerNameInUseError } from '../../../lib/vm/errors'
|
||||
import { getHermesHarnessHostDir, getHermesHostStateDir } from './hermes-paths'
|
||||
|
||||
const CREATE_CONTAINER_MAX_ATTEMPTS = 3
|
||||
const NAME_RELEASE_WAIT_MS = 10_000
|
||||
|
||||
const UNSUPPORTED_PLATFORM_MESSAGE =
|
||||
'browseros-vm currently supports macOS only; see the Linux/Windows tracking issue'
|
||||
|
||||
export interface HermesContainerServiceConfig {
|
||||
resourcesDir?: string
|
||||
browserosDir?: string
|
||||
}
|
||||
|
||||
export interface HermesAccessor {
|
||||
getContainerName(): string
|
||||
getLimaHomeDir(): string
|
||||
getLimactlPath(): string
|
||||
getVmName(): string
|
||||
}
|
||||
|
||||
export class HermesContainerService {
|
||||
private vm: VmRuntime | null = null
|
||||
private shell: ContainerCli | null = null
|
||||
private loader: ImageLoader | null = null
|
||||
private limactlPath: string
|
||||
private limaHome: string
|
||||
private resourcesDir: string | null
|
||||
private browserosDir: string
|
||||
private readonly hermesStateDir: string
|
||||
private readonly platform: NodeJS.Platform
|
||||
private lifecycleLock: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(config: HermesContainerServiceConfig = {}) {
|
||||
this.resourcesDir = config.resourcesDir ?? null
|
||||
this.browserosDir = config.browserosDir ?? getBrowserosDir()
|
||||
this.hermesStateDir = getHermesHostStateDir(this.browserosDir)
|
||||
this.platform = process.platform
|
||||
this.limactlPath = this.resolveLimactlPath()
|
||||
this.limaHome = getLimaHomeDir(this.browserosDir)
|
||||
this.initRuntimes()
|
||||
}
|
||||
|
||||
configure(config: HermesContainerServiceConfig): void {
|
||||
let runtimeChanged = false
|
||||
if (
|
||||
config.resourcesDir !== undefined &&
|
||||
config.resourcesDir !== this.resourcesDir
|
||||
) {
|
||||
this.resourcesDir = config.resourcesDir
|
||||
runtimeChanged = true
|
||||
}
|
||||
if (
|
||||
config.browserosDir !== undefined &&
|
||||
config.browserosDir !== this.browserosDir
|
||||
) {
|
||||
this.browserosDir = config.browserosDir
|
||||
runtimeChanged = true
|
||||
}
|
||||
if (runtimeChanged) {
|
||||
this.limactlPath = this.resolveLimactlPath()
|
||||
this.limaHome = getLimaHomeDir(this.browserosDir)
|
||||
this.initRuntimes()
|
||||
}
|
||||
}
|
||||
|
||||
/** Warm the VM and Hermes image so first-use spawns avoid registry work. */
|
||||
async prewarm(onLog?: (msg: string) => void): Promise<void> {
|
||||
if (!this.isSupportedPlatform()) {
|
||||
logger.warn('Hermes prewarm skipped: unsupported platform', {
|
||||
platform: this.platform,
|
||||
})
|
||||
return
|
||||
}
|
||||
return this.withLifecycleLock('prewarm', async () => {
|
||||
const logProgress = (message: string) => {
|
||||
logger.info(message)
|
||||
onLog?.(message)
|
||||
}
|
||||
logProgress('Hermes prewarm: ensuring BrowserOS VM is ready')
|
||||
await this.requireVm().ensureReady()
|
||||
logProgress(`Hermes prewarm: ensuring image ${HERMES_IMAGE} is available`)
|
||||
await this.requireLoader().ensureImageLoaded(HERMES_IMAGE)
|
||||
logProgress('Hermes prewarm: ready')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a long-running idle container with the harness dir bind-
|
||||
* mounted. The container's default ENTRYPOINT (`hermes acp`) is
|
||||
* overridden with `tini -- sleep infinity` so the container stays
|
||||
* up; the AcpxRuntime spawns `hermes acp` per turn via `nerdctl
|
||||
* exec` and pipes its stdio back through limactl/SSH.
|
||||
*/
|
||||
async start(onLog?: (msg: string) => void): Promise<void> {
|
||||
if (!this.isSupportedPlatform()) {
|
||||
logger.warn('Hermes start skipped: unsupported platform', {
|
||||
platform: this.platform,
|
||||
})
|
||||
return
|
||||
}
|
||||
return this.withLifecycleLock('start', async () => {
|
||||
const logProgress = (msg: string) => {
|
||||
logger.info(msg)
|
||||
onLog?.(msg)
|
||||
}
|
||||
await this.requireVm().ensureReady(logProgress)
|
||||
await this.requireLoader().ensureImageLoaded(HERMES_IMAGE, logProgress)
|
||||
|
||||
// Make sure the host-side harness root exists so the bind-mount
|
||||
// doesn't error on a missing source path. Per-agent home dirs
|
||||
// get created lazily by prepareHermesContext.
|
||||
await mkdir(getHermesHarnessHostDir(this.browserosDir), {
|
||||
recursive: true,
|
||||
})
|
||||
|
||||
logProgress('Hermes: starting idle container...')
|
||||
const container = await this.buildContainerSpec()
|
||||
await this.createContainerWithNameReconcile(container, logProgress)
|
||||
await this.requireShell().startContainer(container.name, logProgress)
|
||||
logProgress(
|
||||
`Hermes container running: ${HERMES_CONTAINER_NAME} (image ${HERMES_IMAGE})`,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.isSupportedPlatform()) return
|
||||
return this.withLifecycleLock('stop', async () => {
|
||||
logger.info('Stopping Hermes container', {
|
||||
container: HERMES_CONTAINER_NAME,
|
||||
})
|
||||
await this.requireShell().removeContainer(
|
||||
HERMES_CONTAINER_NAME,
|
||||
{ force: true },
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async restart(onLog?: (msg: string) => void): Promise<void> {
|
||||
await this.stop()
|
||||
await this.start(onLog)
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
if (!this.isSupportedPlatform()) return
|
||||
try {
|
||||
await this.requireShell().removeContainer(
|
||||
HERMES_CONTAINER_NAME,
|
||||
{ force: true },
|
||||
undefined,
|
||||
)
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Live-getters used by AcpxRuntime to spawn `hermes acp` inside the
|
||||
* container. Returned shape matches `HermesGatewayAccessor` in
|
||||
* acpx-runtime — kept structural here so the type wiring works without
|
||||
* a circular import.
|
||||
*/
|
||||
getAccessor(): HermesAccessor {
|
||||
return {
|
||||
getContainerName: () => HERMES_CONTAINER_NAME,
|
||||
getLimaHomeDir: () => this.limaHome,
|
||||
getLimactlPath: () => this.limactlPath,
|
||||
getVmName: () => VM_NAME,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────
|
||||
|
||||
private isSupportedPlatform(): boolean {
|
||||
return this.platform === 'darwin'
|
||||
}
|
||||
|
||||
private resolveLimactlPath(): string {
|
||||
if (!this.isSupportedPlatform()) return 'limactl'
|
||||
return this.resourcesDir
|
||||
? resolveBundledLimactl(this.resourcesDir)
|
||||
: 'limactl'
|
||||
}
|
||||
|
||||
private initRuntimes(): void {
|
||||
if (!this.isSupportedPlatform()) {
|
||||
this.vm = null
|
||||
this.shell = null
|
||||
this.loader = null
|
||||
return
|
||||
}
|
||||
this.vm = new VmRuntime({
|
||||
limactlPath: this.limactlPath,
|
||||
limaHome: this.limaHome,
|
||||
templatePath: this.resourcesDir
|
||||
? resolveBundledLimaTemplate(this.resourcesDir)
|
||||
: undefined,
|
||||
browserosRoot: this.browserosDir,
|
||||
})
|
||||
this.shell = new ContainerCli({
|
||||
limactlPath: this.limactlPath,
|
||||
limaHome: this.limaHome,
|
||||
vmName: VM_NAME,
|
||||
})
|
||||
this.loader = new ImageLoader(this.shell)
|
||||
}
|
||||
|
||||
private requireVm(): VmRuntime {
|
||||
if (!this.vm) throw unsupportedPlatformError()
|
||||
return this.vm
|
||||
}
|
||||
|
||||
private requireShell(): ContainerCli {
|
||||
if (!this.shell) throw unsupportedPlatformError()
|
||||
return this.shell
|
||||
}
|
||||
|
||||
private requireLoader(): ImageLoader {
|
||||
if (!this.loader) throw unsupportedPlatformError()
|
||||
return this.loader
|
||||
}
|
||||
|
||||
private async buildContainerSpec(): Promise<ContainerSpec> {
|
||||
const guestHarnessDir = `${GUEST_VM_STATE}/hermes/harness`
|
||||
const gateway = await this.requireVm().getDefaultGateway()
|
||||
return {
|
||||
name: HERMES_CONTAINER_NAME,
|
||||
image: HERMES_IMAGE,
|
||||
restart: 'unless-stopped',
|
||||
env: {
|
||||
PYTHONUNBUFFERED: '1',
|
||||
},
|
||||
// Make `host.containers.internal` resolve to the VM's gateway so
|
||||
// hermes inside the container can reach the BrowserOS HTTP server
|
||||
// running on the host (where the BrowserOS MCP /mcp lives). Mirrors
|
||||
// OpenClaw's container-runtime.ts gatewayContainer setup.
|
||||
addHosts: [`host.containers.internal:${gateway}`],
|
||||
mounts: [
|
||||
// Host harness root lives under <browserosDir>/vm/hermes/harness
|
||||
// so it's reachable inside the Lima VM via the existing vm/
|
||||
// mount; container sees it at /data/agents/harness.
|
||||
{ source: guestHarnessDir, target: HERMES_CONTAINER_HARNESS_DIR },
|
||||
],
|
||||
// Override the upstream image's `hermes acp` ENTRYPOINT — we want
|
||||
// a long-lived container that we `nerdctl exec` into per turn,
|
||||
// not one that tries to speak ACP at startup.
|
||||
// Bypass tini and use /bin/sh directly: tini 0.19.0 in the upstream
|
||||
// image getopt-parses any `-x` token (even after the PROGRAM), so
|
||||
// `tini /bin/sh -c "..."` errors with `invalid option -- 'c'`. We
|
||||
// don't need tini reaping zombies for an idle sleeper anyway.
|
||||
entrypoint: '/bin/sh',
|
||||
command: ['-c', 'exec sleep infinity'],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the fixed-name Hermes container, reconciling stale nerdctl
|
||||
* name ownership. Mirrors the OpenClaw service's reconcile loop.
|
||||
*/
|
||||
private async createContainerWithNameReconcile(
|
||||
container: ContainerSpec,
|
||||
onLog?: (msg: string) => void,
|
||||
): Promise<void> {
|
||||
let attempt = 1
|
||||
const shell = this.requireShell()
|
||||
while (true) {
|
||||
await this.removeContainerAndWait(container.name)
|
||||
try {
|
||||
await shell.createContainer(container, onLog)
|
||||
return
|
||||
} catch (err) {
|
||||
if (
|
||||
!(err instanceof ContainerNameInUseError) ||
|
||||
attempt >= CREATE_CONTAINER_MAX_ATTEMPTS
|
||||
) {
|
||||
throw err
|
||||
}
|
||||
logger.warn('Hermes container name still in use; retrying create', {
|
||||
containerName: container.name,
|
||||
attempt,
|
||||
maxAttempts: CREATE_CONTAINER_MAX_ATTEMPTS,
|
||||
})
|
||||
attempt++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async removeContainerAndWait(containerName: string): Promise<void> {
|
||||
const shell = this.requireShell()
|
||||
await shell.removeContainer(containerName, { force: true }, undefined)
|
||||
await shell.waitForContainerNameRelease(containerName, {
|
||||
timeoutMs: NAME_RELEASE_WAIT_MS,
|
||||
intervalMs: 100,
|
||||
})
|
||||
}
|
||||
|
||||
private async withLifecycleLock<T>(
|
||||
operation: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previous = this.lifecycleLock
|
||||
let release!: () => void
|
||||
this.lifecycleLock = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
await previous.catch(() => undefined)
|
||||
try {
|
||||
return await withProcessLock(
|
||||
'hermes-lifecycle',
|
||||
{ lockDir: join(this.hermesStateDir, '.locks') },
|
||||
async () => {
|
||||
logger.debug('Hermes lifecycle operation started', { operation })
|
||||
return await fn()
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unsupportedPlatformError(): Error {
|
||||
return new Error(UNSUPPORTED_PLATFORM_MESSAGE)
|
||||
}
|
||||
|
||||
let service: HermesContainerService | null = null
|
||||
|
||||
export function configureHermesContainerService(
|
||||
config: HermesContainerServiceConfig,
|
||||
): HermesContainerService {
|
||||
if (!service) {
|
||||
service = new HermesContainerService(config)
|
||||
return service
|
||||
}
|
||||
service.configure(config)
|
||||
return service
|
||||
}
|
||||
|
||||
export function getHermesContainerService(): HermesContainerService {
|
||||
if (!service) service = new HermesContainerService()
|
||||
return service
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Host-side path helpers for the Hermes container.
|
||||
*
|
||||
* Hermes per-agent state lives under the BrowserOS-managed VM state
|
||||
* directory (so it's reachable inside the Lima VM via the existing
|
||||
* vm/ → /mnt/browseros/vm bind mount). The Hermes container then bind-
|
||||
* mounts the guest-side path (/mnt/browseros/vm/hermes/harness) into
|
||||
* /data/agents/harness, so `HERMES_HOME` ends up pointing at a path
|
||||
* the container can actually open.
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { getVmStateDir } from '../../../lib/browseros-dir'
|
||||
|
||||
/** Top-level Hermes state directory: `<browserosDir>/vm/hermes`. */
|
||||
export function getHermesHostStateDir(browserosDir?: string): string {
|
||||
return join(
|
||||
browserosDir ? join(browserosDir, 'vm') : getVmStateDir(),
|
||||
'hermes',
|
||||
)
|
||||
}
|
||||
|
||||
/** Per-agent harness root: `<browserosDir>/vm/hermes/harness`. */
|
||||
export function getHermesHarnessHostDir(browserosDir?: string): string {
|
||||
return join(getHermesHostStateDir(browserosDir), 'harness')
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-agent home directory on the host. The Hermes container reads
|
||||
* `config.yaml` + `.env` from here via the harness bind mount; both
|
||||
* files are written at agent-create time by AgentHarnessService and
|
||||
* stay constant across turns.
|
||||
*/
|
||||
export function getHermesAgentHomeHostDir(input: {
|
||||
browserosDir?: string
|
||||
agentId: string
|
||||
}): string {
|
||||
return join(
|
||||
getHermesHarnessHostDir(input.browserosDir),
|
||||
input.agentId,
|
||||
'home',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a Hermes per-agent provider config into the on-host home dir.
|
||||
* The dir lives under <browserosDir>/vm/hermes/harness/<agentId>/home/
|
||||
* which is bind-mounted into the container at /data/agents/harness/<id>/home/.
|
||||
*
|
||||
* Idempotent: writes always overwrite (last-write-wins). The provider
|
||||
* id, env var name, and credentials must be supplied by the caller —
|
||||
* Hermes agents always carry their own config; there is no
|
||||
* `~/.hermes/` fallback.
|
||||
*/
|
||||
export async function writeHermesPerAgentProvider(input: {
|
||||
browserosDir?: string
|
||||
agentId: string
|
||||
providerId: string
|
||||
envVarName: string
|
||||
apiKey: string
|
||||
modelId: string
|
||||
baseUrl?: string
|
||||
}): Promise<void> {
|
||||
const home = getHermesAgentHomeHostDir({
|
||||
browserosDir: input.browserosDir,
|
||||
agentId: input.agentId,
|
||||
})
|
||||
await mkdir(home, { recursive: true })
|
||||
|
||||
const yamlLines = [
|
||||
'model:',
|
||||
` default: ${JSON.stringify(input.modelId)}`,
|
||||
` provider: ${JSON.stringify(input.providerId)}`,
|
||||
]
|
||||
if (input.baseUrl) {
|
||||
yamlLines.push(` base_url: ${JSON.stringify(input.baseUrl)}`)
|
||||
}
|
||||
yamlLines.push('')
|
||||
await writeFile(join(home, 'config.yaml'), yamlLines.join('\n'), {
|
||||
mode: 0o600,
|
||||
})
|
||||
|
||||
const envLines: string[] = [`${input.envVarName}=${input.apiKey}`, '']
|
||||
await writeFile(join(home, '.env'), envLines.join('\n'), { mode: 0o600 })
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Translation table from BrowserOS LLM provider types (the values that
|
||||
* live in `LlmProviderConfig.type` on the extension side) to Hermes
|
||||
* runtime configuration. Hermes itself only knows a small fixed set of
|
||||
* provider keys; BrowserOS exposes a richer registry, so we explicitly
|
||||
* gate which BrowserOS provider types Hermes can consume.
|
||||
*
|
||||
* The set of allowed BrowserOS provider types is shared with the
|
||||
* frontend via `HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES`. Adding a
|
||||
* new type there without an entry here will fail the type check below
|
||||
* (every supported type must have a mapping).
|
||||
*
|
||||
* Anything not listed is rejected at agent-create time with a clear
|
||||
* error — there is no `~/.hermes/` fallback.
|
||||
*/
|
||||
import {
|
||||
HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES,
|
||||
type HermesSupportedBrowserosProviderType,
|
||||
} from '@browseros/shared/constants/hermes'
|
||||
|
||||
export interface HermesProviderMapping {
|
||||
/** Hermes' own provider key written into `model.provider` in config.yaml. */
|
||||
hermesProvider: string
|
||||
/** Env var Hermes reads the API key from (written into per-agent `.env`). */
|
||||
envVarName: string
|
||||
/** True when Hermes needs an explicit `model.base_url` to reach the API. */
|
||||
requiresBaseUrl: boolean
|
||||
}
|
||||
|
||||
const HERMES_PROVIDER_MAP: Record<
|
||||
HermesSupportedBrowserosProviderType,
|
||||
HermesProviderMapping
|
||||
> = {
|
||||
anthropic: {
|
||||
hermesProvider: 'anthropic',
|
||||
envVarName: 'ANTHROPIC_API_KEY',
|
||||
requiresBaseUrl: false,
|
||||
},
|
||||
openai: {
|
||||
hermesProvider: 'openai',
|
||||
envVarName: 'OPENAI_API_KEY',
|
||||
requiresBaseUrl: false,
|
||||
},
|
||||
openrouter: {
|
||||
hermesProvider: 'openrouter',
|
||||
envVarName: 'OPENROUTER_API_KEY',
|
||||
requiresBaseUrl: false,
|
||||
},
|
||||
// BrowserOS' "openai-compatible" type is anything that speaks the OpenAI
|
||||
// wire format on a custom base URL (Together, Groq, etc.). Hermes routes
|
||||
// these through its `openai` provider with `base_url` set.
|
||||
'openai-compatible': {
|
||||
hermesProvider: 'openai',
|
||||
envVarName: 'OPENAI_API_KEY',
|
||||
requiresBaseUrl: true,
|
||||
},
|
||||
}
|
||||
|
||||
export function isHermesSupportedProviderType(
|
||||
providerType: string,
|
||||
): providerType is HermesSupportedBrowserosProviderType {
|
||||
return (
|
||||
HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES as readonly string[]
|
||||
).includes(providerType)
|
||||
}
|
||||
|
||||
export function getHermesProviderMapping(
|
||||
providerType: string,
|
||||
): HermesProviderMapping | undefined {
|
||||
if (!isHermesSupportedProviderType(providerType)) return undefined
|
||||
return HERMES_PROVIDER_MAP[providerType]
|
||||
}
|
||||
@@ -52,7 +52,6 @@ export type GatewayContainerSpec = {
|
||||
hostPort: number
|
||||
hostHome: string
|
||||
envFilePath: string
|
||||
gatewayToken?: string
|
||||
timezone: string
|
||||
}
|
||||
|
||||
@@ -414,9 +413,7 @@ export class ContainerRuntime {
|
||||
TZ: input.timezone,
|
||||
PATH: GATEWAY_PATH,
|
||||
NPM_CONFIG_PREFIX: GATEWAY_NPM_PREFIX,
|
||||
...(input.gatewayToken
|
||||
? { OPENCLAW_GATEWAY_TOKEN: input.gatewayToken }
|
||||
: {}),
|
||||
OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH: '1',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
* resulting AgentHistoryToolCall has both input and output.
|
||||
*/
|
||||
|
||||
import { unwrapBrowserosAcpUserMessage } from '../../../lib/agents/acpx-runtime'
|
||||
import type {
|
||||
AgentHistoryEntry,
|
||||
AgentHistoryToolCall,
|
||||
@@ -35,40 +36,56 @@ const CRON_PROMPT_PREFIX_PATTERN =
|
||||
/^\[cron:[0-9a-f-]+ ([^\]]+)\]\s*([\s\S]*?)\n*Current time:[^\n]*(?:\n[\s\S]*)?$/
|
||||
const CRON_DELIVERY_TRAILER =
|
||||
/\n*Use the message tool if you need to notify the user directly[\s\S]*$/
|
||||
const BROWSEROS_WORKING_DIR_PREFIX = /^\[Working directory:[^\]]*\]\n+/
|
||||
const BROWSEROS_ROLE_BLOCK = /<role>[\s\S]*?<\/role>\n+/
|
||||
const BROWSEROS_USER_REQUEST_BLOCK =
|
||||
/<user_request>\n?([\s\S]*?)\n?<\/user_request>/
|
||||
const BROWSEROS_SYSTEM_REMINDER_BLOCK =
|
||||
/\n*<system-reminder>[\s\S]*?<\/system-reminder>\s*$/
|
||||
const QUEUED_MARKER_LINE =
|
||||
/^\[Queued user message that arrived while the previous turn was still active\]\s*$/m
|
||||
const SUBAGENT_CONTEXT_PREFIX = /^\[Subagent Context\][\s\S]*$/
|
||||
// Emitted by OpenClaw's acp-cli ahead of the BrowserOS envelope. Three
|
||||
// prefix shapes (any combination, in this stack order):
|
||||
//
|
||||
// 1. `[media attached: <internal-path> (<mime>)]` ← per attachment
|
||||
// 2. `[<weekday> <YYYY-MM-DD HH:MM> <TZ>]` ← injectTimestamp
|
||||
// 3. `[Working directory: <path>]` ← acp-cli prefixCwd
|
||||
//
|
||||
// Stacks #1 may appear multiple times (one per image). Stack #2 and #3
|
||||
// can render on the same line separated by a space. Each known prefix is
|
||||
// anchored on its content shape (not just `[…]`) to avoid clobbering
|
||||
// user-typed lines that happen to start with a bracket.
|
||||
const OPENCLAW_MEDIA_PREFIX_LINE = /^\[media attached:[^\]\n]*\]\n/
|
||||
const OPENCLAW_TIMESTAMP_PREFIX =
|
||||
/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) \d{4}-\d{2}-\d{2} \d{2}:\d{2}[^\]\n]*\][ \t]*/
|
||||
const OPENCLAW_WORKDIR_PREFIX = /^\[Working directory: [^\]\n]*\]\n+/
|
||||
|
||||
function stripOpenClawAcpCliEnvelope(value: string): string {
|
||||
let s = value
|
||||
while (OPENCLAW_MEDIA_PREFIX_LINE.test(s)) {
|
||||
s = s.replace(OPENCLAW_MEDIA_PREFIX_LINE, '')
|
||||
}
|
||||
s = s.replace(OPENCLAW_TIMESTAMP_PREFIX, '')
|
||||
s = s.replace(OPENCLAW_WORKDIR_PREFIX, '')
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip OpenClaw + BrowserOS scaffolding from a "user" message before
|
||||
* showing it in the chat panel. The raw prompts contain:
|
||||
* showing it in the chat panel.
|
||||
*
|
||||
* - OpenClaw cron payload prefix:
|
||||
* `[cron:<uuid> <name>] <payload>\nCurrent time: ...\nUse the
|
||||
* message tool if you need to notify the user directly...`
|
||||
* - BrowserOS ACP prefix:
|
||||
* `[Working directory: ...]\n\n<role>...</role>\n\n<user_request>
|
||||
* <actual user text>\n</user_request>\n\n<system-reminder>...</system-reminder>`
|
||||
* - Queued-marker concatenation: when multiple prompts queue while a
|
||||
* turn is active, BrowserOS joins them with the marker line
|
||||
* `[Queued user message that arrived while the previous turn was
|
||||
* still active]`. We split on those markers and clean each chunk
|
||||
* independently, then re-join the non-empty results.
|
||||
* - Subagent context prefix: when an agent invokes a nested subagent,
|
||||
* OpenClaw seeds the subagent's session with `[Subagent Context]
|
||||
* You are running as a subagent (depth N/M). ...` followed by
|
||||
* internal task framing. The actual task lives in the system prompt;
|
||||
* this user message is pure scaffolding and gets dropped entirely.
|
||||
* BrowserOS-side envelope (`<role>…</role>\n\n<user_request>…</user_request>`)
|
||||
* is delegated to `unwrapBrowserosAcpUserMessage`, which performs an
|
||||
* exact-string match against the same constants `buildBrowserosAcpPrompt`
|
||||
* uses to wrap. Matcher and wrapper live in the same repo, so the two
|
||||
* always travel together.
|
||||
*
|
||||
* For each, we extract just the user-facing text. Non-matching messages
|
||||
* fall through unchanged so any future pattern we don't recognize stays
|
||||
* visible rather than getting silently dropped.
|
||||
* OpenClaw's acp-cli prepends a `[Working directory: <path>]\n\n` line
|
||||
* before the BrowserOS envelope (see /app/dist/acp-cli-*.js, line 1361).
|
||||
* We strip that single line up-front so the `^<role>` anchor in
|
||||
* `unwrapBrowserosAcpUserMessage` matches.
|
||||
*
|
||||
* OpenClaw-injected scaffolding (cron prefix, queued-marker, subagent
|
||||
* context) is still pattern-matched here. Removing those requires either
|
||||
* an OpenClaw schema change exposing the structured trigger payload, or a
|
||||
* BrowserOS-side side-channel (cache cron payloads on `cron.add` and look
|
||||
* up by jobId). Tracked as the next cleanup; until then this is best-
|
||||
* effort with text-level patterns.
|
||||
*/
|
||||
export function cleanHistoryUserText(raw: string): string {
|
||||
if (!raw) return raw
|
||||
@@ -102,15 +119,11 @@ function cleanSingleUserMessage(raw: string): string {
|
||||
const payload = cronMatch[2] ?? ''
|
||||
return payload.replace(CRON_DELIVERY_TRAILER, '').trim()
|
||||
}
|
||||
let text = trimmed
|
||||
text = text.replace(BROWSEROS_WORKING_DIR_PREFIX, '')
|
||||
text = text.replace(BROWSEROS_ROLE_BLOCK, '')
|
||||
text = text.replace(BROWSEROS_SYSTEM_REMINDER_BLOCK, '')
|
||||
const userReq = BROWSEROS_USER_REQUEST_BLOCK.exec(text)
|
||||
if (userReq) {
|
||||
return (userReq[1] ?? '').trim()
|
||||
}
|
||||
return text.trim()
|
||||
// Strip OpenClaw's acp-cli envelope (media-attached lines + timestamp
|
||||
// + workdir) before delegating, so the BrowserOS unwrap helper's
|
||||
// `^<role>` anchor matches.
|
||||
const withoutEnvelope = stripOpenClawAcpCliEnvelope(trimmed)
|
||||
return unwrapBrowserosAcpUserMessage(withoutEnvelope).trim()
|
||||
}
|
||||
|
||||
type RichBlock =
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Minimal OpenAI-compatible chat client against the OpenClaw gateway.
|
||||
* Used exclusively by the harness's image carve-out: when the user
|
||||
* attaches images to an OpenClaw agent, the harness diverts the turn
|
||||
* here instead of through the ACP bridge (which silently drops image
|
||||
* content blocks). The gateway's `/v1/chat/completions` endpoint
|
||||
* accepts OpenAI-style multimodal `image_url` parts.
|
||||
*
|
||||
* Output is normalized to `AgentStreamEvent` so the rest of the harness
|
||||
* pipeline (UI streaming, history persistence) doesn't care that the
|
||||
* transport is HTTP rather than ACP for this turn.
|
||||
*/
|
||||
|
||||
import type { AgentStreamEvent } from '../../../lib/agents/types'
|
||||
import { logger } from '../../../lib/logger'
|
||||
|
||||
export type OpenAIContentPart =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image_url'; image_url: { url: string } }
|
||||
|
||||
export interface OpenAIChatMessage {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string | OpenAIContentPart[]
|
||||
}
|
||||
|
||||
export interface GatewayChatTurnInput {
|
||||
/** Gateway-side agent name. Equal to the harness id post Step 9 backfill. */
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
messages: OpenAIChatMessage[]
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export class OpenClawGatewayChatClient {
|
||||
constructor(
|
||||
private readonly getHostPort: () => number,
|
||||
private readonly getToken: () => Promise<string>,
|
||||
) {}
|
||||
|
||||
async streamTurn(
|
||||
input: GatewayChatTurnInput,
|
||||
): Promise<ReadableStream<AgentStreamEvent>> {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${this.getHostPort()}/v1/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: resolveAgentModel(input.agentId),
|
||||
stream: true,
|
||||
messages: input.messages,
|
||||
user: `browseros:${input.agentId}:${input.sessionKey}`,
|
||||
}),
|
||||
signal: input.signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(
|
||||
detail || `OpenClaw gateway chat failed with status ${response.status}`,
|
||||
)
|
||||
}
|
||||
const body = response.body
|
||||
if (!body) {
|
||||
throw new Error('OpenClaw gateway chat response had no body')
|
||||
}
|
||||
|
||||
return new ReadableStream<AgentStreamEvent>({
|
||||
start(controller) {
|
||||
void pumpOpenAIChunks(body, controller, input.signal)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAgentModel(agentId: string): string {
|
||||
// The gateway routes `openclaw` → its default `main` provider config,
|
||||
// and `openclaw/<agentId>` → the per-agent provider config. Backfilled
|
||||
// legacy agents (`main`, orphans) can use the unprefixed form.
|
||||
return agentId === 'main' ? 'openclaw' : `openclaw/${agentId}`
|
||||
}
|
||||
|
||||
async function pumpOpenAIChunks(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
controller: ReadableStreamDefaultController<AgentStreamEvent>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let closed = false
|
||||
let aborted = false
|
||||
let stopReason: string | undefined
|
||||
// Re-emit explicit signal aborts as a clean cancel rather than letting
|
||||
// the underlying `reader.read()` reject — keeps the controller in a
|
||||
// sensible state if the caller bails (e.g. tab close).
|
||||
const onAbort = () => {
|
||||
aborted = true
|
||||
void reader.cancel().catch(() => {})
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
const flushLine = (line: string) => {
|
||||
if (closed || !line.startsWith('data:')) return
|
||||
const payload = line.slice(5).trim()
|
||||
if (!payload || payload === '[DONE]') {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(payload)
|
||||
} catch {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
message: 'Failed to parse OpenClaw gateway chunk',
|
||||
})
|
||||
finish()
|
||||
return
|
||||
}
|
||||
const text = extractDeltaText(parsed)
|
||||
if (text) {
|
||||
controller.enqueue({
|
||||
type: 'text_delta',
|
||||
text,
|
||||
stream: 'output',
|
||||
rawType: 'agent_message_chunk',
|
||||
})
|
||||
}
|
||||
const finishReason = extractFinishReason(parsed)
|
||||
if (finishReason) {
|
||||
stopReason = finishReason === 'stop' ? 'end_turn' : finishReason
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
controller.enqueue({ type: 'done', stopReason: stopReason ?? 'end_turn' })
|
||||
controller.close()
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (aborted) {
|
||||
if (!closed) {
|
||||
closed = true
|
||||
controller.close()
|
||||
}
|
||||
return
|
||||
}
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
let idx = buffer.indexOf('\n\n')
|
||||
while (idx >= 0) {
|
||||
const event = buffer.slice(0, idx)
|
||||
buffer = buffer.slice(idx + 2)
|
||||
for (const line of event.split('\n')) flushLine(line)
|
||||
if (closed) return
|
||||
idx = buffer.indexOf('\n\n')
|
||||
}
|
||||
}
|
||||
if (!closed) {
|
||||
// Stream ended without an explicit [DONE]. Treat as natural end.
|
||||
finish()
|
||||
}
|
||||
} catch (err) {
|
||||
if (closed || aborted) return
|
||||
logger.warn('OpenClaw gateway chat stream errored', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
closed = true
|
||||
controller.close()
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
interface OpenAIStreamChunk {
|
||||
choices?: Array<{
|
||||
delta?: { content?: unknown }
|
||||
finish_reason?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
function extractDeltaText(value: unknown): string {
|
||||
const chunk = value as OpenAIStreamChunk
|
||||
const content = chunk?.choices?.[0]?.delta?.content
|
||||
return typeof content === 'string' ? content : ''
|
||||
}
|
||||
|
||||
function extractFinishReason(value: unknown): string | null {
|
||||
const chunk = value as OpenAIStreamChunk
|
||||
return chunk?.choices?.[0]?.finish_reason ?? null
|
||||
}
|
||||
@@ -92,10 +92,7 @@ export type OpenClawSessionHistoryEvent =
|
||||
| { type: 'error'; data: { message: string } }
|
||||
|
||||
export class OpenClawHttpClient {
|
||||
constructor(
|
||||
private readonly hostPort: number,
|
||||
private readonly getToken: () => Promise<string>,
|
||||
) {}
|
||||
constructor(private readonly hostPort: number) {}
|
||||
|
||||
async getSessionHistory(
|
||||
sessionKey: string,
|
||||
@@ -121,15 +118,9 @@ export class OpenClawHttpClient {
|
||||
|
||||
async isAuthenticated(): Promise<boolean> {
|
||||
try {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${this.hostPort}/v1/models`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
{ method: 'GET' },
|
||||
)
|
||||
return response.ok
|
||||
} catch {
|
||||
@@ -142,15 +133,11 @@ export class OpenClawHttpClient {
|
||||
input: OpenClawSessionHistoryInput,
|
||||
extraHeaders: Record<string, string>,
|
||||
): Promise<Response> {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${this.hostPort}${buildHistoryPath(sessionKey, input)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...extraHeaders,
|
||||
},
|
||||
headers: extraHeaders,
|
||||
signal: input.signal,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Connects to the OpenClaw gateway's WebSocket control plane and pipes
|
||||
* chat broadcast events into a ClawSession state machine. The observer
|
||||
* is a transport layer only — it handles the WS connection lifecycle
|
||||
* (connect, handshake, reconnect) and delegates all state management
|
||||
* to ClawSession.
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import type { ClawSession } from './claw-session'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Protocol types (subset of OpenClaw gateway protocol v3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROTOCOL_VERSION = 3
|
||||
const HANDSHAKE_REQUEST_ID = 'connect'
|
||||
const RECONNECT_DELAY_MS = 5_000
|
||||
const CONNECT_TIMEOUT_MS = 10_000
|
||||
|
||||
interface RequestFrame {
|
||||
type: 'req'
|
||||
id: string
|
||||
method: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
|
||||
type IncomingFrame =
|
||||
| { type: 'res'; id: string; ok: true; payload?: unknown }
|
||||
| {
|
||||
type: 'res'
|
||||
id: string
|
||||
ok: false
|
||||
error: { code: string; message: string }
|
||||
}
|
||||
| { type: 'event'; event: string; payload?: unknown }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Observer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class OpenClawObserver {
|
||||
private ws: WebSocket | null = null
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private connected = false
|
||||
private closed = false
|
||||
private gatewayUrl: string | null = null
|
||||
private gatewayToken: string | null = null
|
||||
|
||||
constructor(private readonly session: ClawSession) {}
|
||||
|
||||
/** Start observing the gateway at the given URL with the given token. */
|
||||
connect(gatewayUrl: string, token: string): void {
|
||||
this.gatewayUrl = gatewayUrl
|
||||
this.gatewayToken = token
|
||||
this.closed = false
|
||||
this.doConnect()
|
||||
}
|
||||
|
||||
/** Stop observing and close the WebSocket. */
|
||||
disconnect(): void {
|
||||
this.closed = true
|
||||
this.clearReconnect()
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.close()
|
||||
} catch {}
|
||||
this.ws = null
|
||||
}
|
||||
this.connected = false
|
||||
}
|
||||
|
||||
/** Whether the observer has an active WS connection. */
|
||||
isConnected(): boolean {
|
||||
return this.connected
|
||||
}
|
||||
|
||||
// ── Private ─────────────────────────────────────────────────────────
|
||||
|
||||
private doConnect(): void {
|
||||
if (this.closed || !this.gatewayUrl || !this.gatewayToken) return
|
||||
|
||||
const wsUrl = this.gatewayUrl
|
||||
.replace(/^http:\/\//, 'ws://')
|
||||
.replace(/^https:\/\//, 'wss://')
|
||||
|
||||
logger.debug('OpenClaw observer connecting', { url: wsUrl })
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
this.ws = ws
|
||||
|
||||
const connectTimeout = setTimeout(() => {
|
||||
logger.warn('OpenClaw observer handshake timeout')
|
||||
ws.terminate()
|
||||
}, CONNECT_TIMEOUT_MS)
|
||||
|
||||
let handshakeSent = false
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
let frame: IncomingFrame
|
||||
try {
|
||||
frame = JSON.parse(raw.toString('utf8')) as IncomingFrame
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// The gateway sends a connect.challenge event before accepting
|
||||
// the connect request. Send the handshake after receiving it.
|
||||
if (
|
||||
frame.type === 'event' &&
|
||||
frame.event === 'connect.challenge' &&
|
||||
!handshakeSent
|
||||
) {
|
||||
handshakeSent = true
|
||||
const connectReq: RequestFrame = {
|
||||
type: 'req',
|
||||
id: HANDSHAKE_REQUEST_ID,
|
||||
method: 'connect',
|
||||
params: {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: 'openclaw-tui',
|
||||
displayName: 'browseros-observer',
|
||||
version: '1.0.0',
|
||||
platform: 'node',
|
||||
mode: 'ui',
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: ['operator.read'],
|
||||
auth: { token: this.gatewayToken },
|
||||
},
|
||||
}
|
||||
ws.send(JSON.stringify(connectReq))
|
||||
return
|
||||
}
|
||||
|
||||
// Handshake response
|
||||
if (frame.type === 'res' && frame.id === HANDSHAKE_REQUEST_ID) {
|
||||
clearTimeout(connectTimeout)
|
||||
if (frame.ok) {
|
||||
this.connected = true
|
||||
logger.info('OpenClaw observer connected')
|
||||
} else {
|
||||
logger.warn('OpenClaw observer handshake failed', {
|
||||
error: frame.error,
|
||||
})
|
||||
ws.close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Broadcast events (only process after handshake completes)
|
||||
if (frame.type === 'event' && this.connected) {
|
||||
this.handleEvent(frame.event, frame.payload)
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
clearTimeout(connectTimeout)
|
||||
this.connected = false
|
||||
this.ws = null
|
||||
|
||||
// Reset any agents stuck in "working" to "unknown" — we missed
|
||||
// the final/end event because the WS closed mid-task. The
|
||||
// ClawSession will re-infer correct state from JSONL when the
|
||||
// observer reconnects and ensureObserverConnected() re-seeds.
|
||||
for (const [agentId, state] of this.session.getAllStates()) {
|
||||
if (state.status === 'working') {
|
||||
this.session.transition(agentId, 'unknown')
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.closed) {
|
||||
logger.debug('OpenClaw observer disconnected, scheduling reconnect')
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('error', (err) => {
|
||||
clearTimeout(connectTimeout)
|
||||
logger.debug('OpenClaw observer WS error', {
|
||||
message: err.message,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private handleEvent(eventName: string, payload: unknown): void {
|
||||
if (eventName === 'chat') {
|
||||
this.handleChatEvent(payload)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a gateway chat broadcast event and transition the ClawSession
|
||||
* state machine accordingly.
|
||||
*/
|
||||
private handleChatEvent(payload: unknown): void {
|
||||
if (!payload || typeof payload !== 'object') return
|
||||
const p = payload as Record<string, unknown>
|
||||
|
||||
const sessionKey = typeof p.sessionKey === 'string' ? p.sessionKey : null
|
||||
const state = typeof p.state === 'string' ? p.state : null
|
||||
|
||||
if (!sessionKey || !state) return
|
||||
|
||||
const agentId = extractAgentId(sessionKey)
|
||||
if (!agentId) return
|
||||
|
||||
if (state === 'delta' || state === 'streaming') {
|
||||
this.session.transition(agentId, 'working', {
|
||||
sessionKey,
|
||||
currentTool: extractToolName(p),
|
||||
})
|
||||
} else if (state === 'final' || state === 'end') {
|
||||
this.session.transition(agentId, 'idle', { sessionKey })
|
||||
} else if (state === 'error') {
|
||||
const errorMsg =
|
||||
typeof p.errorMessage === 'string'
|
||||
? p.errorMessage
|
||||
: typeof p.error === 'string'
|
||||
? p.error
|
||||
: 'Unknown error'
|
||||
this.session.transition(agentId, 'error', { sessionKey, error: errorMsg })
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.clearReconnect()
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.doConnect()
|
||||
}, RECONNECT_DELAY_MS)
|
||||
}
|
||||
|
||||
private clearReconnect(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract agentId from an OpenClaw session key.
|
||||
* Format: "agent:<agentId>:..." — we take the segment after "agent:".
|
||||
*/
|
||||
function extractAgentId(sessionKey: string): string | null {
|
||||
if (!sessionKey.startsWith('agent:')) return null
|
||||
const colonIdx = sessionKey.indexOf(':', 6)
|
||||
if (colonIdx === -1) return sessionKey.slice(6)
|
||||
return sessionKey.slice(6, colonIdx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to extract a tool name from a chat event payload.
|
||||
*/
|
||||
function extractToolName(payload: Record<string, unknown>): string | null {
|
||||
if (typeof payload.toolName === 'string') return payload.toolName
|
||||
if (typeof payload.tool === 'string') return payload.tool
|
||||
const content = payload.content
|
||||
if (content && typeof content === 'object' && 'name' in content) {
|
||||
const name = (content as Record<string, unknown>).name
|
||||
if (typeof name === 'string') return name
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
OPENCLAW_IMAGE,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
|
||||
import type { AgentStreamEvent } from '../../../lib/agents/types'
|
||||
import { getOpenClawDir } from '../../../lib/browseros-dir'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import { withProcessLock } from '../../../lib/process-lock'
|
||||
@@ -64,7 +65,6 @@ import {
|
||||
type OpenClawSessionHistoryEvent,
|
||||
type OpenClawSessionHistoryMessage,
|
||||
} from './openclaw-http-client'
|
||||
import { OpenClawObserver } from './openclaw-observer'
|
||||
import {
|
||||
type ResolvedOpenClawProviderConfig,
|
||||
resolveSupportedOpenClawProvider,
|
||||
@@ -360,8 +360,6 @@ export class OpenClawService {
|
||||
private httpClient: OpenClawHttpClient
|
||||
private openclawDir: string
|
||||
private hostPort = OPENCLAW_GATEWAY_CONTAINER_PORT
|
||||
private token: string
|
||||
private tokenLoaded = false
|
||||
private lastError: string | null = null
|
||||
private browserosServerPort: number
|
||||
private resourcesDir: string | null
|
||||
@@ -372,7 +370,6 @@ export class OpenClawService {
|
||||
private stopLogTail: (() => void) | null = null
|
||||
private lifecycleLock: Promise<void> = Promise.resolve()
|
||||
private clawSession = new ClawSession()
|
||||
private observer = new OpenClawObserver(this.clawSession)
|
||||
|
||||
constructor(config: OpenClawServiceConfig = {}) {
|
||||
this.openclawDir = getOpenClawDir()
|
||||
@@ -381,13 +378,9 @@ export class OpenClawService {
|
||||
projectDir: this.openclawDir,
|
||||
browserosRoot: config.browserosDir,
|
||||
})
|
||||
this.token = crypto.randomUUID()
|
||||
this.cliClient = new OpenClawCliClient(this.runtime)
|
||||
this.bootstrapCliClient = this.buildBootstrapCliClient()
|
||||
this.httpClient = new OpenClawHttpClient(
|
||||
this.hostPort,
|
||||
async () => this.token,
|
||||
)
|
||||
this.httpClient = new OpenClawHttpClient(this.hostPort)
|
||||
this.browserosServerPort =
|
||||
config.browserosServerPort ?? DEFAULT_PORTS.server
|
||||
this.resourcesDir = config.resourcesDir ?? null
|
||||
@@ -423,19 +416,6 @@ export class OpenClawService {
|
||||
return this.hostPort
|
||||
}
|
||||
|
||||
/**
|
||||
* Current gateway auth token. The token string is loaded from
|
||||
* `gateway.auth.token` in the persisted openclaw.json during setup,
|
||||
* with a freshly generated UUID as fallback. Exposed so the ACPx
|
||||
* harness can pass it to spawned `openclaw acp` child processes via
|
||||
* the documented `OPENCLAW_GATEWAY_TOKEN` env var (avoids both the
|
||||
* `--token` process-listing leak and reliance on a token-file path
|
||||
* that doesn't exist as a discrete file inside the container).
|
||||
*/
|
||||
getGatewayToken(): string {
|
||||
return this.token
|
||||
}
|
||||
|
||||
/** Subscribe to real-time agent status changes from the ClawSession state machine. */
|
||||
onAgentStatusChange(
|
||||
listener: (agentId: string, state: AgentSessionState) => void,
|
||||
@@ -448,6 +428,70 @@ export class OpenClawService {
|
||||
return this.clawSession.getState(agentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the live-status state machine from a turn lifecycle event the
|
||||
* AgentHarnessService observed. Replaces the previous WS observer
|
||||
* pipeline that re-tapped the same gateway events; the harness already
|
||||
* sees them as ACP `session/update` notifications, so we forward those
|
||||
* here. Caller passes the stream events verbatim.
|
||||
*
|
||||
* `tool_call` and `tool_call_update` populate `currentTool` so the
|
||||
* dashboard SSE keeps its existing payload shape. `done` clears
|
||||
* working state to `idle`; `error` keeps a sticky error badge.
|
||||
*/
|
||||
recordAgentTurnEvent(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
event:
|
||||
| { type: 'turn_started' }
|
||||
| { type: 'turn_event'; event: AgentStreamEvent }
|
||||
| { type: 'turn_ended'; error?: string },
|
||||
): void {
|
||||
if (event.type === 'turn_started') {
|
||||
this.clawSession.transition(agentId, 'working', { sessionKey })
|
||||
return
|
||||
}
|
||||
if (event.type === 'turn_ended') {
|
||||
if (event.error !== undefined) {
|
||||
this.clawSession.transition(agentId, 'error', {
|
||||
sessionKey,
|
||||
error: event.error,
|
||||
})
|
||||
} else {
|
||||
this.clawSession.transition(agentId, 'idle', { sessionKey })
|
||||
}
|
||||
return
|
||||
}
|
||||
const inner = event.event
|
||||
if (inner.type === 'tool_call') {
|
||||
this.clawSession.transition(agentId, 'working', {
|
||||
sessionKey,
|
||||
currentTool: inner.title ?? null,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (inner.type === 'error') {
|
||||
this.clawSession.transition(agentId, 'error', {
|
||||
sessionKey,
|
||||
error: inner.message,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (inner.type === 'done') {
|
||||
this.clawSession.transition(agentId, 'idle', { sessionKey })
|
||||
return
|
||||
}
|
||||
if (inner.type === 'text_delta') {
|
||||
// Heartbeat — keep the existing `working` row fresh; preserve
|
||||
// the last-known currentTool by passing it through.
|
||||
const prev = this.clawSession.getState(agentId)
|
||||
this.clawSession.transition(agentId, 'working', {
|
||||
sessionKey,
|
||||
currentTool: prev.currentTool,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
/** Warm the VM and gateway image so later setup/start avoids registry work. */
|
||||
@@ -494,14 +538,13 @@ export class OpenClawService {
|
||||
providerKeyCount: Object.keys(provider.envValues).length,
|
||||
})
|
||||
|
||||
await this.refreshGatewayAuthToken()
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
|
||||
logProgress('Bootstrapping OpenClaw config...')
|
||||
await this.bootstrapCliClient.runOnboard({
|
||||
acceptRisk: true,
|
||||
authChoice: 'skip',
|
||||
gatewayAuth: 'token',
|
||||
gatewayAuth: 'none',
|
||||
gatewayBind: 'lan',
|
||||
gatewayPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
installDaemon: false,
|
||||
@@ -518,8 +561,6 @@ export class OpenClawService {
|
||||
logProgress('Validating OpenClaw config...')
|
||||
await this.assertConfigValid(this.bootstrapCliClient)
|
||||
|
||||
await this.refreshGatewayAuthToken()
|
||||
|
||||
logProgress('Starting OpenClaw gateway...')
|
||||
await this.runtime.startGateway(
|
||||
this.buildGatewayRuntimeSpec(),
|
||||
@@ -578,8 +619,6 @@ export class OpenClawService {
|
||||
|
||||
await this.runtime.ensureReady(logProgress)
|
||||
|
||||
logProgress('Refreshing gateway auth token...')
|
||||
await this.refreshGatewayAuthToken()
|
||||
await this.ensureStateEnvFile()
|
||||
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
@@ -633,7 +672,6 @@ export class OpenClawService {
|
||||
return this.withLifecycleLock('stop', async () => {
|
||||
logger.info('Stopping OpenClaw service', { hostPort: this.hostPort })
|
||||
this.controlPlaneStatus = 'disconnected'
|
||||
this.observer.disconnect()
|
||||
this.stopGatewayLogTail()
|
||||
await this.runtime.stopGateway()
|
||||
logger.info('OpenClaw container stopped')
|
||||
@@ -650,8 +688,6 @@ export class OpenClawService {
|
||||
this.controlPlaneStatus = 'reconnecting'
|
||||
await this.runtime.ensureReady(logProgress)
|
||||
this.stopGatewayLogTail()
|
||||
logProgress('Refreshing gateway auth token...')
|
||||
await this.refreshGatewayAuthToken()
|
||||
await this.ensureStateEnvFile()
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
logProgress('Restarting OpenClaw gateway...')
|
||||
@@ -696,8 +732,6 @@ export class OpenClawService {
|
||||
throw new Error('OpenClaw gateway is not ready')
|
||||
}
|
||||
|
||||
logProgress('Reloading gateway auth token...')
|
||||
await this.refreshGatewayAuthToken()
|
||||
this.controlPlaneStatus = 'reconnecting'
|
||||
logProgress('Reconnecting control plane...')
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
@@ -707,7 +741,6 @@ export class OpenClawService {
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.controlPlaneStatus = 'disconnected'
|
||||
this.observer.disconnect()
|
||||
this.stopGatewayLogTail()
|
||||
try {
|
||||
await this.runtime.stopGateway()
|
||||
@@ -1117,7 +1150,6 @@ export class OpenClawService {
|
||||
try {
|
||||
await this.runtime.ensureReady()
|
||||
|
||||
await this.refreshGatewayAuthToken()
|
||||
await this.ensureStateEnvFile()
|
||||
|
||||
const persistedPort = await readPersistedGatewayPort(this.openclawDir)
|
||||
@@ -1247,10 +1279,7 @@ export class OpenClawService {
|
||||
private setPort(hostPort: number): void {
|
||||
if (hostPort === this.hostPort) return
|
||||
this.hostPort = hostPort
|
||||
this.httpClient = new OpenClawHttpClient(
|
||||
this.hostPort,
|
||||
async () => this.token,
|
||||
)
|
||||
this.httpClient = new OpenClawHttpClient(this.hostPort)
|
||||
}
|
||||
|
||||
private async ensureGatewayPortAllocated(
|
||||
@@ -1283,25 +1312,13 @@ export class OpenClawService {
|
||||
}
|
||||
|
||||
private async isGatewayAuthenticated(hostPort: number): Promise<boolean> {
|
||||
if (!this.tokenLoaded) {
|
||||
logger.debug(
|
||||
'OpenClaw gateway port is ready before auth token is loaded',
|
||||
{
|
||||
hostPort,
|
||||
},
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const client =
|
||||
hostPort === this.hostPort
|
||||
? this.httpClient
|
||||
: new OpenClawHttpClient(hostPort, async () => this.token)
|
||||
: new OpenClawHttpClient(hostPort)
|
||||
const authenticated = await client.isAuthenticated()
|
||||
if (!authenticated) {
|
||||
logger.warn('OpenClaw gateway port rejected current auth token', {
|
||||
hostPort,
|
||||
})
|
||||
logger.warn('OpenClaw gateway readiness probe failed', { hostPort })
|
||||
}
|
||||
return authenticated
|
||||
}
|
||||
@@ -1342,12 +1359,10 @@ export class OpenClawService {
|
||||
|
||||
private async runControlPlaneCall<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
await this.ensureTokenLoaded()
|
||||
const result = await fn()
|
||||
this.controlPlaneStatus = 'connected'
|
||||
this.lastGatewayError = null
|
||||
this.lastRecoveryReason = null
|
||||
this.ensureObserverConnected()
|
||||
return result
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
@@ -1359,20 +1374,10 @@ export class OpenClawService {
|
||||
}
|
||||
}
|
||||
|
||||
private ensureObserverConnected(): void {
|
||||
if (this.observer.isConnected()) return
|
||||
// ClawSession starts empty after the JSONL seed was removed; the WS
|
||||
// observer fills in agent status as events arrive.
|
||||
const url = `http://127.0.0.1:${this.hostPort}`
|
||||
this.observer.connect(url, this.token)
|
||||
}
|
||||
|
||||
private classifyControlPlaneError(
|
||||
error: unknown,
|
||||
): OpenClawGatewayRecoveryReason {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (message.includes('Unauthorized')) return 'token_mismatch'
|
||||
if (message.includes('token')) return 'token_mismatch'
|
||||
if (message.includes('not ready')) return 'container_not_ready'
|
||||
return 'unknown'
|
||||
}
|
||||
@@ -1600,7 +1605,6 @@ export class OpenClawService {
|
||||
hostPort: this.hostPort,
|
||||
hostHome: this.openclawDir,
|
||||
envFilePath: this.getStateEnvPath(),
|
||||
gatewayToken: this.tokenLoaded ? this.token : undefined,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
}
|
||||
}
|
||||
@@ -1705,50 +1709,6 @@ export class OpenClawService {
|
||||
return true
|
||||
}
|
||||
|
||||
private async ensureTokenLoaded(): Promise<void> {
|
||||
if (this.tokenLoaded) {
|
||||
return
|
||||
}
|
||||
if (!existsSync(this.getStateConfigPath())) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.loadTokenFromConfig()
|
||||
}
|
||||
|
||||
private async refreshGatewayAuthToken(): Promise<void> {
|
||||
this.tokenLoaded = false
|
||||
if (!existsSync(this.getStateConfigPath())) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.loadTokenFromConfig()
|
||||
}
|
||||
|
||||
private async loadTokenFromConfig(): Promise<void> {
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
await readFile(this.getStateConfigPath(), 'utf-8'),
|
||||
) as {
|
||||
gateway?: {
|
||||
auth?: {
|
||||
token?: unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
const token = config.gateway?.auth?.token
|
||||
if (typeof token === 'string' && token) {
|
||||
this.token = token
|
||||
this.tokenLoaded = true
|
||||
logger.info('Loaded OpenClaw gateway token from mounted config')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to load OpenClaw gateway token from mounted config', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private createProgressLogger(
|
||||
onLog?: (msg: string) => void,
|
||||
): (msg: string) => void {
|
||||
|
||||
@@ -4,16 +4,11 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { createRuntimeStore } from 'acpx/runtime'
|
||||
import type { OpenClawGatewayChatClient } from '../../api/services/openclaw/openclaw-gateway-chat-client'
|
||||
import type { AgentDefinition } from './agent-types'
|
||||
import { prepareClaudeCodeContext } from './claude-code/prepare'
|
||||
import { prepareCodexContext } from './codex/prepare'
|
||||
import {
|
||||
maybeHandleOpenClawTurn,
|
||||
prepareOpenClawContext,
|
||||
} from './openclaw/prepare'
|
||||
import type { AgentPromptInput, AgentStreamEvent } from './types'
|
||||
import { prepareHermesContext } from './hermes/prepare'
|
||||
import { prepareOpenClawContext } from './openclaw/prepare'
|
||||
|
||||
export interface PreparedAcpxAgentContext {
|
||||
cwd: string
|
||||
@@ -22,6 +17,14 @@ export interface PreparedAcpxAgentContext {
|
||||
commandEnv: Record<string, string>
|
||||
commandIdentity: string
|
||||
useBrowserosMcp: boolean
|
||||
/**
|
||||
* Hostname the agent should use to reach the BrowserOS HTTP MCP server.
|
||||
* Default `127.0.0.1` is correct for host-process adapters (claude, codex,
|
||||
* Phase A host-mode hermes). Container-spawned adapters override this to
|
||||
* `host.containers.internal` so the URL injected into ACP newSession's
|
||||
* mcpServers resolves from inside the container.
|
||||
*/
|
||||
browserosMcpHost?: string
|
||||
openclawSessionKey: string | null
|
||||
}
|
||||
|
||||
@@ -35,29 +38,17 @@ export interface PrepareAcpxAgentContextInput {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface AcpxAdapterTurnInput {
|
||||
prompt: AgentPromptInput
|
||||
prepared: PreparedAcpxAgentContext
|
||||
sessionStore: ReturnType<typeof createRuntimeStore>
|
||||
openclawGatewayChat: OpenClawGatewayChatClient | null
|
||||
}
|
||||
|
||||
export interface AcpxAgentAdapter {
|
||||
prepare(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext>
|
||||
maybeHandleTurn?(
|
||||
input: AcpxAdapterTurnInput,
|
||||
): Promise<ReadableStream<AgentStreamEvent> | null>
|
||||
}
|
||||
|
||||
const ADAPTERS: Record<AgentDefinition['adapter'], AcpxAgentAdapter> = {
|
||||
claude: { prepare: prepareClaudeCodeContext },
|
||||
codex: { prepare: prepareCodexContext },
|
||||
openclaw: {
|
||||
prepare: prepareOpenClawContext,
|
||||
maybeHandleTurn: maybeHandleOpenClawTurn,
|
||||
},
|
||||
openclaw: { prepare: prepareOpenClawContext },
|
||||
hermes: { prepare: prepareHermesContext },
|
||||
}
|
||||
|
||||
export function getAcpxAgentAdapter(
|
||||
|
||||
@@ -57,6 +57,7 @@ export async function finishBrowserosManagedContext(input: {
|
||||
skillNames: string[]
|
||||
promptPrefix: string
|
||||
commandEnv: Record<string, string>
|
||||
browserosMcpHost?: string
|
||||
}): Promise<PreparedAcpxAgentContext> {
|
||||
const commandIdentity = stableCommandIdentity(input.commandEnv)
|
||||
const runtimeSessionKey = deriveRuntimeSessionKey({
|
||||
@@ -83,6 +84,7 @@ export async function finishBrowserosManagedContext(input: {
|
||||
commandEnv: input.commandEnv,
|
||||
commandIdentity,
|
||||
useBrowserosMcp: true,
|
||||
browserosMcpHost: input.browserosMcpHost,
|
||||
openclawSessionKey: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,9 @@ import {
|
||||
createAgentRegistry,
|
||||
createRuntimeStore,
|
||||
} from 'acpx/runtime'
|
||||
import type { OpenClawGatewayChatClient } from '../../api/services/openclaw/openclaw-gateway-chat-client'
|
||||
import { getBrowserosDir } from '../browseros-dir'
|
||||
import { logger } from '../logger'
|
||||
import {
|
||||
getAcpxAgentAdapter,
|
||||
prepareAcpxAgentContext,
|
||||
} from './acpx-agent-adapter'
|
||||
import { prepareAcpxAgentContext } from './acpx-agent-adapter'
|
||||
import {
|
||||
resolveAgentRuntimePaths,
|
||||
wrapCommandWithEnv,
|
||||
@@ -51,11 +47,10 @@ import type {
|
||||
* when spawning the openclaw ACP adapter inside the gateway container.
|
||||
*
|
||||
* Fields are getters (not snapshot values) so the harness picks up the
|
||||
* current token and VM/container paths at spawn time.
|
||||
* current VM/container paths at spawn time. The bundled gateway runs
|
||||
* with `gateway.auth.mode=none`, so no auth token is plumbed through.
|
||||
*/
|
||||
export interface OpenclawGatewayAccessor {
|
||||
/** Current gateway auth token. Passed to `openclaw acp --token`. */
|
||||
getGatewayToken(): string
|
||||
/** Container name e.g. browseros-openclaw-openclaw-gateway-1. */
|
||||
getContainerName(): string
|
||||
/** LIMA_HOME directory containing the browseros-vm instance. */
|
||||
@@ -66,6 +61,23 @@ export interface OpenclawGatewayAccessor {
|
||||
getVmName(): string
|
||||
}
|
||||
|
||||
/**
|
||||
* Live-getter access to the Hermes container runtime info. Required
|
||||
* when spawning the hermes ACP adapter inside its long-running idle
|
||||
* container; absent only in tests / dev fallback (where the harness
|
||||
* still falls back to a host-process `hermes acp` spawn).
|
||||
*/
|
||||
export interface HermesGatewayAccessor {
|
||||
/** Container name e.g. browseros-hermes-hermes-agent-1. */
|
||||
getContainerName(): string
|
||||
/** LIMA_HOME directory containing the browseros-vm instance. */
|
||||
getLimaHomeDir(): string
|
||||
/** Resolved path to the `limactl` binary (bundled or host). */
|
||||
getLimactlPath(): string
|
||||
/** VM name registered in LIMA_HOME (e.g. browseros-vm). */
|
||||
getVmName(): string
|
||||
}
|
||||
|
||||
type AcpxRuntimeOptions = {
|
||||
cwd?: string
|
||||
browserosDir?: string
|
||||
@@ -77,14 +89,11 @@ type AcpxRuntimeOptions = {
|
||||
*/
|
||||
openclawGateway?: OpenclawGatewayAccessor
|
||||
/**
|
||||
* Optional. When wired, the runtime diverts OpenClaw turns that
|
||||
* carry image attachments to the gateway's HTTP `/v1/chat/completions`
|
||||
* endpoint (which accepts OpenAI-style `image_url` parts) instead of
|
||||
* the ACP bridge — the bridge silently drops image content blocks.
|
||||
* Without this client, image turns to OpenClaw agents fall through to
|
||||
* the ACP path and the model never sees the image.
|
||||
* Required for adapter='hermes' agents in production; absence falls
|
||||
* back to a host-process `hermes acp` spawn (used by tests / dev
|
||||
* before the container service is wired).
|
||||
*/
|
||||
openclawGatewayChat?: OpenClawGatewayChatClient
|
||||
hermesGateway?: HermesGatewayAccessor
|
||||
runtimeFactory?: (options: AcpRuntimeOptions) => AcpxCoreRuntime
|
||||
}
|
||||
|
||||
@@ -95,6 +104,7 @@ interface PreparedRuntimeContext {
|
||||
agentCommandEnv: Record<string, string>
|
||||
commandIdentity: string
|
||||
useBrowserosMcp: boolean
|
||||
browserosMcpHost?: string
|
||||
openclawSessionKey: string | null
|
||||
}
|
||||
|
||||
@@ -104,7 +114,7 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
private readonly stateDir: string
|
||||
private readonly browserosServerPort: number
|
||||
private readonly openclawGateway: OpenclawGatewayAccessor | null
|
||||
private readonly openclawGatewayChat: OpenClawGatewayChatClient | null
|
||||
private readonly hermesGateway: HermesGatewayAccessor | null
|
||||
private readonly runtimeFactory: (
|
||||
options: AcpRuntimeOptions,
|
||||
) => AcpxCoreRuntime
|
||||
@@ -121,7 +131,7 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
this.browserosServerPort =
|
||||
options.browserosServerPort ?? DEFAULT_PORTS.server
|
||||
this.openclawGateway = options.openclawGateway ?? null
|
||||
this.openclawGatewayChat = options.openclawGatewayChat ?? null
|
||||
this.hermesGateway = options.hermesGateway ?? null
|
||||
this.sessionStore = createRuntimeStore({ stateDir: this.stateDir })
|
||||
this.runtimeFactory = options.runtimeFactory ?? createAcpRuntime
|
||||
}
|
||||
@@ -199,24 +209,6 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
imageAttachmentCount: imageAttachments.length,
|
||||
})
|
||||
|
||||
const adapter = getAcpxAgentAdapter(input.agent.adapter)
|
||||
const adapterStream =
|
||||
(await adapter.maybeHandleTurn?.({
|
||||
prompt: input,
|
||||
prepared: {
|
||||
cwd: prepared.cwd,
|
||||
runtimeSessionKey: prepared.runtimeSessionKey,
|
||||
runPrompt: prepared.runPrompt,
|
||||
commandEnv: prepared.agentCommandEnv,
|
||||
commandIdentity: prepared.commandIdentity,
|
||||
useBrowserosMcp: prepared.useBrowserosMcp,
|
||||
openclawSessionKey: prepared.openclawSessionKey,
|
||||
},
|
||||
sessionStore: this.sessionStore,
|
||||
openclawGatewayChat: this.openclawGatewayChat,
|
||||
})) ?? null
|
||||
if (adapterStream) return adapterStream
|
||||
|
||||
const runtime = this.getRuntime({
|
||||
cwd,
|
||||
permissionMode: input.permissionMode,
|
||||
@@ -224,6 +216,7 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
commandEnv: prepared.agentCommandEnv,
|
||||
commandIdentity: prepared.commandIdentity,
|
||||
useBrowserosMcp: prepared.useBrowserosMcp,
|
||||
browserosMcpHost: prepared.browserosMcpHost,
|
||||
openclawSessionKey: prepared.openclawSessionKey,
|
||||
})
|
||||
|
||||
@@ -271,6 +264,7 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
agentCommandEnv: prepared.commandEnv,
|
||||
commandIdentity: prepared.commandIdentity,
|
||||
useBrowserosMcp: prepared.useBrowserosMcp,
|
||||
browserosMcpHost: prepared.browserosMcpHost,
|
||||
openclawSessionKey: prepared.openclawSessionKey,
|
||||
}
|
||||
}
|
||||
@@ -282,14 +276,17 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
commandEnv: Record<string, string>
|
||||
commandIdentity: string
|
||||
useBrowserosMcp: boolean
|
||||
browserosMcpHost?: string
|
||||
openclawSessionKey: string | null
|
||||
}): AcpxCoreRuntime {
|
||||
const mcpHost = input.browserosMcpHost ?? '127.0.0.1'
|
||||
const key = JSON.stringify({
|
||||
cwd: input.cwd,
|
||||
permissionMode: input.permissionMode,
|
||||
nonInteractivePermissions: input.nonInteractivePermissions,
|
||||
commandIdentity: input.commandIdentity,
|
||||
useBrowserosMcp: input.useBrowserosMcp,
|
||||
browserosMcpHost: mcpHost,
|
||||
openclawSessionKey: input.openclawSessionKey,
|
||||
})
|
||||
const existing = this.runtimes.get(key)
|
||||
@@ -301,10 +298,11 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
agentRegistry: createBrowserosAgentRegistry({
|
||||
openclawGateway: this.openclawGateway,
|
||||
openclawSessionKey: input.openclawSessionKey,
|
||||
hermesGateway: this.hermesGateway,
|
||||
commandEnv: input.commandEnv,
|
||||
}),
|
||||
mcpServers: input.useBrowserosMcp
|
||||
? createBrowserosMcpServers(this.browserosServerPort)
|
||||
? createBrowserosMcpServers(this.browserosServerPort, mcpHost)
|
||||
: [],
|
||||
permissionMode: input.permissionMode,
|
||||
nonInteractivePermissions: input.nonInteractivePermissions,
|
||||
@@ -316,6 +314,7 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
permissionMode: input.permissionMode,
|
||||
nonInteractivePermissions: input.nonInteractivePermissions,
|
||||
browserosServerPort: this.browserosServerPort,
|
||||
browserosMcpHost: mcpHost,
|
||||
commandIdentity: input.commandIdentity,
|
||||
useBrowserosMcp: input.useBrowserosMcp,
|
||||
openclawSessionKey: input.openclawSessionKey,
|
||||
@@ -696,12 +695,13 @@ function createAcpxEventStream(
|
||||
|
||||
function createBrowserosMcpServers(
|
||||
browserosServerPort: number,
|
||||
host = '127.0.0.1',
|
||||
): NonNullable<AcpRuntimeOptions['mcpServers']> {
|
||||
return [
|
||||
{
|
||||
type: 'http',
|
||||
name: 'browseros',
|
||||
url: `http://127.0.0.1:${browserosServerPort}/mcp`,
|
||||
url: `http://${host}:${browserosServerPort}/mcp`,
|
||||
headers: [],
|
||||
},
|
||||
]
|
||||
@@ -710,6 +710,7 @@ function createBrowserosMcpServers(
|
||||
function createBrowserosAgentRegistry(input: {
|
||||
openclawGateway: OpenclawGatewayAccessor | null
|
||||
openclawSessionKey: string | null
|
||||
hermesGateway: HermesGatewayAccessor | null
|
||||
commandEnv: Record<string, string>
|
||||
}): AcpRuntimeOptions['agentRegistry'] {
|
||||
const registry = createAgentRegistry()
|
||||
@@ -736,6 +737,19 @@ function createBrowserosAgentRegistry(input: {
|
||||
)
|
||||
}
|
||||
|
||||
if (lower === 'hermes') {
|
||||
if (!input.hermesGateway) {
|
||||
// No container service wired (tests, dev fallback). Spawn the
|
||||
// host-side `hermes` binary if available. acpx's built-in
|
||||
// hermes registry resolution is good enough — we just inject
|
||||
// HERMES_HOME via the env wrapper so per-agent state still
|
||||
// works. Production wires `hermesGateway` and goes through
|
||||
// the container path below.
|
||||
return wrapCommandWithEnv('hermes acp', input.commandEnv)
|
||||
}
|
||||
return resolveHermesAcpCommand(input.hermesGateway, input.commandEnv)
|
||||
}
|
||||
|
||||
if (lower === 'claude' || lower === 'codex') {
|
||||
return wrapCommandWithEnv(registry.resolve(agentName), input.commandEnv)
|
||||
}
|
||||
@@ -745,6 +759,66 @@ function createBrowserosAgentRegistry(input: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the command string acpx will spawn for a `hermes` adapter.
|
||||
* Runs `hermes acp` inside the long-running Hermes container via the
|
||||
* bundled `limactl shell <vm> -- nerdctl exec -i ...` chain so the
|
||||
* upstream image's `hermes` binary is reused; BrowserOS does not
|
||||
* require a host-side hermes install.
|
||||
*
|
||||
* HERMES_HOME inside the container points at the per-agent home
|
||||
* directory (translated from the host-side path by prepareHermesContext)
|
||||
* so each BrowserOS agent stays isolated.
|
||||
*
|
||||
* Stdio: with `limactl shell -- nerdctl exec`, hermes' stdio inside the
|
||||
* container is a real pipe(2), and bytes flow back through limactl/SSH
|
||||
* to the harness. The Bun↔Python socketpair workaround that the
|
||||
* Phase A host-process spawn needed (`bash -c "... | tee /dev/null"`) is
|
||||
* unnecessary here because the multiple pipe→socket conversions
|
||||
* naturally absorb the issue.
|
||||
*/
|
||||
function resolveHermesAcpCommand(
|
||||
gateway: HermesGatewayAccessor,
|
||||
commandEnv: Record<string, string>,
|
||||
): string {
|
||||
const limactl = gateway.getLimactlPath()
|
||||
const vm = gateway.getVmName()
|
||||
const container = gateway.getContainerName()
|
||||
const limaHome = gateway.getLimaHomeDir()
|
||||
|
||||
// Build `nerdctl exec -i -e <K=V> ... <container> hermes acp`. The
|
||||
// commandEnv typically carries HERMES_HOME (a /data/... path inside
|
||||
// the container set by prepareHermesContext); pass each value through
|
||||
// `-e` so hermes sees them, even though the env itself was set on the
|
||||
// host process. PYTHONUNBUFFERED is sticky on the container; we
|
||||
// re-add it here for safety.
|
||||
const argv = [
|
||||
'env',
|
||||
`LIMA_HOME=${limaHome}`,
|
||||
limactl,
|
||||
'shell',
|
||||
'--workdir',
|
||||
'/',
|
||||
vm,
|
||||
'--',
|
||||
'nerdctl',
|
||||
'exec',
|
||||
'-i',
|
||||
'-e',
|
||||
'PYTHONUNBUFFERED=1',
|
||||
]
|
||||
for (const [key, value] of Object.entries(commandEnv)) {
|
||||
argv.push('-e', `${key}=${value}`)
|
||||
}
|
||||
// The upstream nousresearch/hermes-agent image installs hermes into
|
||||
// /opt/hermes/.venv/bin (note the leading dot). It's not on PATH for
|
||||
// arbitrary `nerdctl exec` calls — the image's own entrypoint script
|
||||
// sets the PATH but we override the entrypoint to keep the container
|
||||
// idle, so we use the absolute path here.
|
||||
argv.push(container, '/opt/hermes/.venv/bin/hermes', 'acp')
|
||||
return argv.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the command string acpx will spawn for an `openclaw` adapter.
|
||||
* Runs `openclaw acp` inside the gateway container via the bundled
|
||||
@@ -752,8 +826,8 @@ function createBrowserosAgentRegistry(input: {
|
||||
* already installed alongside the gateway is reused; BrowserOS does
|
||||
* not require a host-side openclaw install.
|
||||
*
|
||||
* Auth: `openclaw acp --url ...` deliberately does not reuse implicit
|
||||
* env/config credentials, so pass the gateway token explicitly.
|
||||
* Auth: BrowserOS configures the bundled gateway with `gateway.auth.mode=none`,
|
||||
* so no gateway token flag is needed for the local ACP bridge.
|
||||
*
|
||||
* Banner output: OPENCLAW_HIDE_BANNER and OPENCLAW_SUPPRESS_NOTES
|
||||
* suppress non-JSON-RPC chatter on stdout that would otherwise corrupt
|
||||
@@ -763,7 +837,6 @@ function resolveOpenclawAcpCommand(
|
||||
gateway: OpenclawGatewayAccessor,
|
||||
sessionKey: string | null,
|
||||
): string {
|
||||
const token = gateway.getGatewayToken()
|
||||
const limactl = gateway.getLimactlPath()
|
||||
const vm = gateway.getVmName()
|
||||
const container = gateway.getContainerName()
|
||||
@@ -812,8 +885,6 @@ function resolveOpenclawAcpCommand(
|
||||
'acp',
|
||||
'--url',
|
||||
gatewayUrlInsideContainer,
|
||||
'--token',
|
||||
token,
|
||||
]
|
||||
if (bridgeSessionKey) {
|
||||
argv.push('--session', bridgeSessionKey)
|
||||
|
||||
@@ -91,7 +91,7 @@ export class RingBuffer {
|
||||
/** Frames with seq > fromSeq, plus the terminal frame if not already in the slice. */
|
||||
slice(fromSeq: number): TurnFrame[] {
|
||||
const live = this.frames.filter((f) => f.seq > fromSeq)
|
||||
if (this.terminal && !live.some((f) => f.seq === this.terminal!.seq)) {
|
||||
if (this.terminal && !live.some((f) => f.seq === this.terminal?.seq)) {
|
||||
// Terminal might have been evicted by overflow; re-attach it so
|
||||
// subscribers always see a terminal if one exists.
|
||||
if (this.terminal.seq > fromSeq) live.push(this.terminal)
|
||||
|
||||
@@ -84,6 +84,24 @@ export const AGENT_ADAPTER_CATALOG: AgentAdapterDescriptor[] = [
|
||||
{ id: 'adaptive', label: 'Adaptive' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hermes',
|
||||
name: 'Hermes',
|
||||
// 'default' means whatever the user configured via `hermes setup` —
|
||||
// Hermes' config.yaml is the source of truth for the model. ACP exposes
|
||||
// session/set_model but we don't surface it in Phase A.
|
||||
defaultModelId: 'default',
|
||||
defaultReasoningEffort: 'medium',
|
||||
modelControl: 'best-effort',
|
||||
// Empty list signals "no per-session model picker" — like OpenClaw.
|
||||
// Phase A.5 may dynamically populate from session/new response.
|
||||
models: [],
|
||||
reasoningEfforts: [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium', recommended: true },
|
||||
{ id: 'high', label: 'High' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function getAgentAdapterDescriptor(
|
||||
@@ -93,7 +111,12 @@ export function getAgentAdapterDescriptor(
|
||||
}
|
||||
|
||||
export function isAgentAdapter(value: unknown): value is AgentAdapter {
|
||||
return value === 'claude' || value === 'codex' || value === 'openclaw'
|
||||
return (
|
||||
value === 'claude' ||
|
||||
value === 'codex' ||
|
||||
value === 'openclaw' ||
|
||||
value === 'hermes'
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveDefaultModelId(adapter: AgentAdapter): string {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export type AgentAdapter = 'claude' | 'codex' | 'openclaw'
|
||||
export type AgentAdapter = 'claude' | 'codex' | 'openclaw' | 'hermes'
|
||||
|
||||
export type AgentPermissionMode = 'approve-all'
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
|
||||
import { HERMES_CONTAINER_HARNESS_DIR } from '@browseros/shared/constants/hermes'
|
||||
import {
|
||||
getHermesAgentHomeHostDir,
|
||||
getHermesHarnessHostDir,
|
||||
} from '../../../api/services/hermes/hermes-paths'
|
||||
import type {
|
||||
PrepareAcpxAgentContextInput,
|
||||
PreparedAcpxAgentContext,
|
||||
} from '../acpx-agent-adapter'
|
||||
import {
|
||||
finishBrowserosManagedContext,
|
||||
prepareBrowserosManagedContext,
|
||||
} from '../acpx-agent-common'
|
||||
|
||||
/**
|
||||
* Translate a host-side hermes home path to its in-container equivalent.
|
||||
* The container bind-mounts `<browserosDir>/vm/hermes/harness` (host)
|
||||
* onto `/data/agents/harness` (container), so paths under the host
|
||||
* harness root map cleanly to `/data/agents/harness/...` inside.
|
||||
*
|
||||
* Returns the original host path when it doesn't sit under the harness
|
||||
* root — used as a defensive escape hatch (tests that inject a custom
|
||||
* dir, or future host-process fallback that still goes through this
|
||||
* prepare step).
|
||||
*/
|
||||
function translateHermesHomeToContainerPath(
|
||||
hostHome: string,
|
||||
browserosDir: string,
|
||||
): string {
|
||||
const harnessHostRoot = getHermesHarnessHostDir(browserosDir)
|
||||
if (hostHome === harnessHostRoot) return HERMES_CONTAINER_HARNESS_DIR
|
||||
if (hostHome.startsWith(`${harnessHostRoot}/`)) {
|
||||
return `${HERMES_CONTAINER_HARNESS_DIR}${hostHome.slice(harnessHostRoot.length)}`
|
||||
}
|
||||
return hostHome
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares Hermes with a per-agent HERMES_HOME under
|
||||
* `<browserosDir>/vm/hermes/harness/<id>/home`. The provider config
|
||||
* (config.yaml + .env) was written into this directory at agent-create
|
||||
* time by AgentHarnessService.writeHermesPerAgentProvider. There is no
|
||||
* fallback to a global `~/.hermes/` install — Hermes agents always
|
||||
* carry their own provider config.
|
||||
*
|
||||
* HERMES_HOME inside the container is the container-side path
|
||||
* (`/data/agents/harness/<id>/home`) so Hermes resolves it correctly
|
||||
* when the runtime spawns `hermes acp` via `nerdctl exec`.
|
||||
*/
|
||||
export async function prepareHermesContext(
|
||||
input: PrepareAcpxAgentContextInput,
|
||||
): Promise<PreparedAcpxAgentContext> {
|
||||
const common = await prepareBrowserosManagedContext(input)
|
||||
|
||||
// Hermes-specific home lives under vm/ so it's reachable inside the
|
||||
// Lima VM; the shared `common.paths.agentHome` (under agents/harness)
|
||||
// is OUTSIDE the VM mount and would not be visible to nerdctl.
|
||||
const hermesAgentHome = getHermesAgentHomeHostDir({
|
||||
browserosDir: input.browserosDir,
|
||||
agentId: input.agent.id,
|
||||
})
|
||||
await mkdir(hermesAgentHome, { recursive: true })
|
||||
|
||||
const hermesAgentHomeInContainer = translateHermesHomeToContainerPath(
|
||||
hermesAgentHome,
|
||||
input.browserosDir,
|
||||
)
|
||||
|
||||
return finishBrowserosManagedContext({
|
||||
...common,
|
||||
commandEnv: {
|
||||
HERMES_HOME: hermesAgentHomeInContainer,
|
||||
},
|
||||
// Hermes runs inside a Lima container; the BrowserOS HTTP MCP server
|
||||
// lives on the host. `host.containers.internal` resolves to the VM
|
||||
// gateway (via --add-host on the hermes-agent container) so hermes can
|
||||
// reach the MCP endpoint that the harness injects via newSession.
|
||||
browserosMcpHost: 'host.containers.internal',
|
||||
})
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AcpSessionRecord, createRuntimeStore } from 'acpx/runtime'
|
||||
import type {
|
||||
OpenAIChatMessage,
|
||||
OpenAIContentPart,
|
||||
} from '../../../api/services/openclaw/openclaw-gateway-chat-client'
|
||||
import { logger } from '../../logger'
|
||||
import type { AcpxAdapterTurnInput } from '../acpx-agent-adapter'
|
||||
import type { AgentStreamEvent } from '../types'
|
||||
|
||||
type ImageAttachment = Readonly<{ mediaType: string; data: string }>
|
||||
|
||||
export async function maybeHandleOpenClawTurn(
|
||||
input: AcpxAdapterTurnInput,
|
||||
): Promise<ReadableStream<AgentStreamEvent> | null> {
|
||||
const imageAttachments = (input.prompt.attachments ?? []).filter((a) =>
|
||||
a.mediaType.startsWith('image/'),
|
||||
)
|
||||
if (imageAttachments.length === 0 || !input.openclawGatewayChat) {
|
||||
return null
|
||||
}
|
||||
return sendOpenclawViaGateway({
|
||||
prompt: input.prompt,
|
||||
sessionStore: input.sessionStore,
|
||||
openclawGatewayChat: input.openclawGatewayChat,
|
||||
imageAttachments,
|
||||
cwd: input.prepared.cwd,
|
||||
runPrompt: input.prepared.runPrompt,
|
||||
})
|
||||
}
|
||||
|
||||
/** Handles OpenClaw image turns through the gateway HTTP chat endpoint. */
|
||||
async function sendOpenclawViaGateway(input: {
|
||||
prompt: AcpxAdapterTurnInput['prompt']
|
||||
sessionStore: AcpxAdapterTurnInput['sessionStore']
|
||||
openclawGatewayChat: NonNullable<AcpxAdapterTurnInput['openclawGatewayChat']>
|
||||
imageAttachments: ReadonlyArray<ImageAttachment>
|
||||
cwd: string
|
||||
runPrompt: string
|
||||
}): Promise<ReadableStream<AgentStreamEvent>> {
|
||||
const existingRecord = await input.sessionStore.load(input.prompt.sessionKey)
|
||||
const priorMessages = existingRecord
|
||||
? recordToOpenAIMessages(existingRecord)
|
||||
: []
|
||||
const userContent: OpenAIContentPart[] = [
|
||||
{
|
||||
type: 'text',
|
||||
text: input.runPrompt,
|
||||
},
|
||||
...input.imageAttachments.map(
|
||||
(a): OpenAIContentPart => ({
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:${a.mediaType};base64,${a.data}` },
|
||||
}),
|
||||
),
|
||||
]
|
||||
const messages: OpenAIChatMessage[] = [
|
||||
...priorMessages,
|
||||
{ role: 'user', content: userContent },
|
||||
]
|
||||
|
||||
logger.info('Agent harness gateway image turn dispatched', {
|
||||
agentId: input.prompt.agent.id,
|
||||
sessionKey: input.prompt.sessionKey,
|
||||
cwd: input.cwd,
|
||||
priorMessageCount: priorMessages.length,
|
||||
imageAttachmentCount: input.imageAttachments.length,
|
||||
})
|
||||
|
||||
const upstream = await input.openclawGatewayChat.streamTurn({
|
||||
agentId: input.prompt.agent.id,
|
||||
sessionKey: input.prompt.sessionKey,
|
||||
messages,
|
||||
signal: input.prompt.signal,
|
||||
})
|
||||
|
||||
const sessionStore = input.sessionStore
|
||||
const sessionKey = input.prompt.sessionKey
|
||||
const userMessageText = input.prompt.message
|
||||
const imageAttachments = input.imageAttachments
|
||||
let accumulated = ''
|
||||
|
||||
return new ReadableStream<AgentStreamEvent>({
|
||||
start: (controller) => {
|
||||
const reader = upstream.getReader()
|
||||
const persist = async () => {
|
||||
if (!existingRecord || !accumulated) return
|
||||
try {
|
||||
await persistGatewayTurn(
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
userMessageText,
|
||||
imageAttachments,
|
||||
accumulated,
|
||||
)
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'Failed to persist gateway image turn to acpx session record',
|
||||
{
|
||||
sessionKey,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
;(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (value.type === 'text_delta') accumulated += value.text
|
||||
controller.enqueue(value)
|
||||
}
|
||||
await persist()
|
||||
controller.close()
|
||||
} catch (err) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
controller.close()
|
||||
}
|
||||
})().catch(() => {})
|
||||
},
|
||||
cancel: () => {
|
||||
// Best-effort: cancel propagation to the gateway is tracked separately.
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function persistGatewayTurn(
|
||||
sessionStore: ReturnType<typeof createRuntimeStore>,
|
||||
sessionKey: string,
|
||||
userMessageText: string,
|
||||
imageAttachments: ReadonlyArray<ImageAttachment>,
|
||||
assistantText: string,
|
||||
): Promise<void> {
|
||||
const record = await sessionStore.load(sessionKey)
|
||||
if (!record) return
|
||||
const userContent: AcpxUserContent[] = [
|
||||
{ Text: userMessageText } as AcpxUserContent,
|
||||
]
|
||||
for (const _image of imageAttachments) {
|
||||
userContent.push({ Image: { source: 'base64' } } as AcpxUserContent)
|
||||
}
|
||||
const turnId = randomUUID()
|
||||
const updated = {
|
||||
...record,
|
||||
messages: [
|
||||
...record.messages,
|
||||
{ User: { id: `user-${turnId}`, content: userContent } },
|
||||
{ Agent: { content: [{ Text: assistantText }], tool_results: {} } },
|
||||
],
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
} as AcpSessionRecord
|
||||
await sessionStore.save(updated)
|
||||
}
|
||||
|
||||
function recordToOpenAIMessages(record: AcpSessionRecord): OpenAIChatMessage[] {
|
||||
const messages: OpenAIChatMessage[] = []
|
||||
for (const message of record.messages) {
|
||||
if (message === 'Resume') continue
|
||||
if ('User' in message) {
|
||||
const text = message.User.content
|
||||
.map(userContentToText)
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
.trim()
|
||||
if (text) messages.push({ role: 'user', content: text })
|
||||
continue
|
||||
}
|
||||
if ('Agent' in message) {
|
||||
const text = message.Agent.content
|
||||
.map((part) => ('Text' in part ? part.Text : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
if (text) messages.push({ role: 'assistant', content: text })
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
type AcpxSessionMessage = AcpSessionRecord['messages'][number]
|
||||
type AcpxUserContent = Extract<
|
||||
Exclude<AcpxSessionMessage, 'Resume'>,
|
||||
{ User: unknown }
|
||||
>['User']['content'][number]
|
||||
|
||||
function userContentToText(content: AcpxUserContent): string {
|
||||
if ('Text' in content) return unwrapPromptText(content.Text)
|
||||
if ('Mention' in content) return content.Mention.content
|
||||
if ('Image' in content) return content.Image.source ? '[image]' : ''
|
||||
return ''
|
||||
}
|
||||
|
||||
function unwrapPromptText(raw: string): string {
|
||||
const runtimeMatch = raw.match(
|
||||
/^<browseros_acpx_runtime\b[\s\S]*?<\/browseros_acpx_runtime>\n\n<user_request>\n([\s\S]*?)\n<\/user_request>$/,
|
||||
)
|
||||
if (runtimeMatch) return decodeBasicEntities(runtimeMatch[1]).trim()
|
||||
const roleMatch = raw.match(
|
||||
/^<role>[\s\S]*?<\/role>\n\n<user_request>\n([\s\S]*?)\n<\/user_request>$/,
|
||||
)
|
||||
if (roleMatch) return decodeBasicEntities(roleMatch[1]).trim()
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
function decodeBasicEntities(value: string): string {
|
||||
return value
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
}
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
resolveAgentRuntimePaths,
|
||||
} from '../acpx-runtime-context'
|
||||
|
||||
export { maybeHandleOpenClawTurn } from './image-turn'
|
||||
|
||||
const OPENCLAW_BROWSEROS_ACP_INSTRUCTIONS =
|
||||
'<role>You are running inside BrowserOS through the OpenClaw ACP adapter. Use your OpenClaw identity, memory, and browser tools.</role>'
|
||||
|
||||
|
||||
@@ -235,6 +235,7 @@ function buildCreateArgs(spec: ContainerSpec): string[] {
|
||||
args.push('--health-retries', String(spec.health.retries))
|
||||
}
|
||||
}
|
||||
if (spec.entrypoint) args.push('--entrypoint', spec.entrypoint)
|
||||
|
||||
args.push(spec.image)
|
||||
args.push(...(spec.command ?? []))
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import {
|
||||
HERMES_AGENT_NAME,
|
||||
HERMES_IMAGE,
|
||||
} from '@browseros/shared/constants/hermes'
|
||||
import {
|
||||
OPENCLAW_AGENT_NAME,
|
||||
OPENCLAW_IMAGE,
|
||||
@@ -35,10 +39,14 @@ export class ImageLoader {
|
||||
|
||||
/** Resolve BrowserOS agent names to image refs and ensure the image exists. */
|
||||
async ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise<string> {
|
||||
if (name !== OPENCLAW_AGENT_NAME) {
|
||||
throw new ImageLoadError(name, `no agent image mapping: ${name}`)
|
||||
if (name === OPENCLAW_AGENT_NAME) {
|
||||
await this.ensureImageLoaded(OPENCLAW_IMAGE, onLog)
|
||||
return OPENCLAW_IMAGE
|
||||
}
|
||||
await this.ensureImageLoaded(OPENCLAW_IMAGE, onLog)
|
||||
return OPENCLAW_IMAGE
|
||||
if (name === HERMES_AGENT_NAME) {
|
||||
await this.ensureImageLoaded(HERMES_IMAGE, onLog)
|
||||
return HERMES_IMAGE
|
||||
}
|
||||
throw new ImageLoadError(name, `no agent image mapping: ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,14 @@ export interface ContainerSpec {
|
||||
mounts?: MountSpec[]
|
||||
addHosts?: string[]
|
||||
health?: HealthConfig
|
||||
/**
|
||||
* Override the image's ENTRYPOINT. When set, nerdctl is invoked with
|
||||
* `--entrypoint <value>`; the `command` array is appended as args to
|
||||
* this entrypoint. Useful for keeping a service-style image alive in
|
||||
* the background (e.g. `tini -- sh -c "exec sleep infinity"`) so that
|
||||
* other code paths can `nerdctl exec` into it per turn.
|
||||
*/
|
||||
entrypoint?: string
|
||||
command?: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export const agentDefinitions = sqliteTable(
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
adapter: text('adapter', {
|
||||
enum: ['claude', 'codex', 'openclaw'],
|
||||
enum: ['claude', 'codex', 'openclaw', 'hermes'],
|
||||
}).notNull(),
|
||||
modelId: text('model_id').notNull(),
|
||||
reasoningEffort: text('reasoning_effort').notNull(),
|
||||
|
||||
@@ -12,6 +12,10 @@ import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { EXIT_CODES } from '@browseros/shared/constants/exit-codes'
|
||||
import { createHttpServer } from './api/server'
|
||||
import {
|
||||
configureHermesContainerService,
|
||||
getHermesContainerService,
|
||||
} from './api/services/hermes/hermes-container'
|
||||
import {
|
||||
configureOpenClawService,
|
||||
configureVmRuntime,
|
||||
@@ -150,6 +154,33 @@ export class Application {
|
||||
})
|
||||
}
|
||||
|
||||
// Hermes container is also best-effort — same crash isolation
|
||||
// semantics as OpenClaw above. Image is pulled in the background;
|
||||
// an idle container is brought up so per-turn `nerdctl exec hermes acp`
|
||||
// calls from the harness don't pay container-create latency.
|
||||
try {
|
||||
const hermesService = configureHermesContainerService({
|
||||
resourcesDir,
|
||||
})
|
||||
void hermesService.prewarm().catch((err) =>
|
||||
logger.warn('Hermes prewarm failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
)
|
||||
void hermesService.start().catch((err) =>
|
||||
logger.warn('Hermes container start failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'Hermes container configuration failed, continuing without it',
|
||||
{
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
metrics.log('http_server.started', { version: VERSION })
|
||||
}
|
||||
|
||||
@@ -159,6 +190,9 @@ export class Application {
|
||||
getOpenClawService()
|
||||
.shutdown()
|
||||
.catch(() => {})
|
||||
getHermesContainerService()
|
||||
.shutdown()
|
||||
.catch(() => {})
|
||||
removeServerConfigSync()
|
||||
|
||||
// Immediate exit without graceful shutdown. Chromium may kill us on update/restart,
|
||||
|
||||
@@ -696,7 +696,7 @@ function createFakeService(agents: AgentDefinition[]) {
|
||||
},
|
||||
async createAgent(input: {
|
||||
name: string
|
||||
adapter: 'claude' | 'codex' | 'openclaw'
|
||||
adapter: 'claude' | 'codex' | 'openclaw' | 'hermes'
|
||||
modelId?: string
|
||||
reasoningEffort?: string
|
||||
}) {
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AgentHarnessService } from '../../../../src/api/services/agents/agent-harness-service'
|
||||
import type { AgentStore } from '../../../../src/lib/agents/agent-store'
|
||||
import type { AgentDefinition } from '../../../../src/lib/agents/agent-types'
|
||||
@@ -440,6 +443,171 @@ describe('AgentHarnessService', () => {
|
||||
const listed = await service.listAgentsWithActivity()
|
||||
expect(listed[0]?.status).toBe('error')
|
||||
})
|
||||
|
||||
it('writes a per-agent Hermes config.yaml + .env when adapter=hermes and provider config complete', async () => {
|
||||
const agents: AgentDefinition[] = []
|
||||
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: createAgentStore(agents) as AgentStore,
|
||||
runtime: stubRuntime(),
|
||||
browserosDir,
|
||||
})
|
||||
|
||||
const agent = await service.createAgent({
|
||||
name: 'Hermes bot',
|
||||
adapter: 'hermes',
|
||||
providerType: 'openrouter',
|
||||
apiKey: 'sk-or-v1-test-key',
|
||||
modelId: 'anthropic/claude-haiku-4.5',
|
||||
})
|
||||
|
||||
const homeDir = join(
|
||||
browserosDir,
|
||||
'vm',
|
||||
'hermes',
|
||||
'harness',
|
||||
agent.id,
|
||||
'home',
|
||||
)
|
||||
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
|
||||
const env = readFileSync(join(homeDir, '.env'), 'utf8')
|
||||
expect(yaml).toContain('"openrouter"')
|
||||
expect(yaml).toContain('"anthropic/claude-haiku-4.5"')
|
||||
expect(env).toContain('OPENROUTER_API_KEY=sk-or-v1-test-key')
|
||||
})
|
||||
|
||||
it('rejects Hermes agent creation when apiKey is missing', async () => {
|
||||
const agents: AgentDefinition[] = []
|
||||
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: createAgentStore(agents) as AgentStore,
|
||||
runtime: stubRuntime(),
|
||||
browserosDir,
|
||||
})
|
||||
|
||||
await expect(
|
||||
service.createAgent({
|
||||
name: 'Hermes bot',
|
||||
adapter: 'hermes',
|
||||
providerType: 'openrouter',
|
||||
modelId: 'anthropic/claude-haiku-4.5',
|
||||
}),
|
||||
).rejects.toThrow(/apiKey/i)
|
||||
expect(agents).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects Hermes agent creation when providerType is missing', async () => {
|
||||
const agents: AgentDefinition[] = []
|
||||
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: createAgentStore(agents) as AgentStore,
|
||||
runtime: stubRuntime(),
|
||||
browserosDir,
|
||||
})
|
||||
|
||||
await expect(
|
||||
service.createAgent({ name: 'Hermes bot', adapter: 'hermes' }),
|
||||
).rejects.toThrow(/providerType/i)
|
||||
expect(agents).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects Hermes agent creation when modelId is missing', async () => {
|
||||
const agents: AgentDefinition[] = []
|
||||
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: createAgentStore(agents) as AgentStore,
|
||||
runtime: stubRuntime(),
|
||||
browserosDir,
|
||||
})
|
||||
|
||||
await expect(
|
||||
service.createAgent({
|
||||
name: 'Hermes bot',
|
||||
adapter: 'hermes',
|
||||
providerType: 'openrouter',
|
||||
apiKey: 'sk-or-v1-test-key',
|
||||
}),
|
||||
).rejects.toThrow(/modelId/i)
|
||||
expect(agents).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('writes Hermes per-agent base_url for openai-compatible providers (mapped to Hermes openai key)', async () => {
|
||||
const agents: AgentDefinition[] = []
|
||||
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: createAgentStore(agents) as AgentStore,
|
||||
runtime: stubRuntime(),
|
||||
browserosDir,
|
||||
})
|
||||
|
||||
const agent = await service.createAgent({
|
||||
name: 'Custom Hermes',
|
||||
adapter: 'hermes',
|
||||
providerType: 'openai-compatible',
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'my-model',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
})
|
||||
|
||||
const homeDir = join(
|
||||
browserosDir,
|
||||
'vm',
|
||||
'hermes',
|
||||
'harness',
|
||||
agent.id,
|
||||
'home',
|
||||
)
|
||||
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
|
||||
const env = readFileSync(join(homeDir, '.env'), 'utf8')
|
||||
// BrowserOS' openai-compatible type routes through Hermes' `openai`
|
||||
// provider with base_url set.
|
||||
expect(yaml).toContain('"openai"')
|
||||
expect(yaml).toContain('"my-model"')
|
||||
expect(yaml).toContain('"https://api.example.com/v1"')
|
||||
expect(env).toContain('OPENAI_API_KEY=sk-test')
|
||||
})
|
||||
|
||||
it('rejects openai-compatible Hermes agent creation when baseUrl is missing', async () => {
|
||||
const agents: AgentDefinition[] = []
|
||||
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: createAgentStore(agents) as AgentStore,
|
||||
runtime: stubRuntime(),
|
||||
browserosDir,
|
||||
})
|
||||
|
||||
await expect(
|
||||
service.createAgent({
|
||||
name: 'Custom Hermes',
|
||||
adapter: 'hermes',
|
||||
providerType: 'openai-compatible',
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'my-model',
|
||||
}),
|
||||
).rejects.toThrow(/baseUrl/i)
|
||||
expect(agents).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects Hermes agent creation when providerType is not in the supported set', async () => {
|
||||
const agents: AgentDefinition[] = []
|
||||
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: createAgentStore(agents) as AgentStore,
|
||||
runtime: stubRuntime(),
|
||||
browserosDir,
|
||||
})
|
||||
|
||||
await expect(
|
||||
service.createAgent({
|
||||
name: 'Unknown Hermes',
|
||||
adapter: 'hermes',
|
||||
providerType: 'bedrock',
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'm',
|
||||
}),
|
||||
).rejects.toThrow(/not supported/i)
|
||||
expect(agents).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
function stubRuntime(): AgentRuntime {
|
||||
|
||||
@@ -159,6 +159,31 @@ describe('ContainerRuntime', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('passes private-ingress no-auth only when requested', async () => {
|
||||
const deps = createDeps()
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
})
|
||||
|
||||
await runtime.startGateway({
|
||||
...defaultSpec,
|
||||
gatewayToken: undefined,
|
||||
privateIngressNoAuth: true,
|
||||
})
|
||||
|
||||
expect(deps.shell.createContainer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH: '1',
|
||||
}),
|
||||
}),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it('delegates ensureReady and stopVm to VmRuntime', async () => {
|
||||
const deps = createDeps()
|
||||
const runtime = new ContainerRuntime({
|
||||
|
||||
@@ -31,21 +31,52 @@ describe('cleanHistoryUserText', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Mirrors `BROWSEROS_ACP_AGENT_INSTRUCTIONS` from acpx-runtime.ts.
|
||||
// `unwrapBrowserosAcpUserMessage`'s `stripOuterRoleEnvelope` performs
|
||||
// an exact-prefix/suffix match against this constant, so test fixtures
|
||||
// need the full text — not a truncated stand-in.
|
||||
const ROLE_BLOCK =
|
||||
'<role>\n' +
|
||||
'You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.\n\n' +
|
||||
'Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.\n' +
|
||||
'</role>'
|
||||
|
||||
it('unwraps the BrowserOS ACP user_request envelope', () => {
|
||||
const raw =
|
||||
'[Working directory: /tmp/workspace]\n\n' +
|
||||
'<role>\nYou are BrowserOS - a browser agent...\n</role>\n\n' +
|
||||
'<user_request>\nhey\n</user_request>'
|
||||
const raw = `${ROLE_BLOCK}\n\n<user_request>\nhey\n</user_request>`
|
||||
expect(cleanHistoryUserText(raw)).toBe('hey')
|
||||
})
|
||||
|
||||
it('strips a trailing system-reminder block', () => {
|
||||
it("strips OpenClaw acp-cli's leading [Working directory:] line", () => {
|
||||
// OpenClaw 2026.5.x's acp-cli prepends `[Working directory: <path>]
|
||||
// \n\n` before the BrowserOS envelope. We strip that line up-front
|
||||
// so the inner `<role>…</role>\n\n<user_request>` envelope can be
|
||||
// unwrapped by `unwrapBrowserosAcpUserMessage`.
|
||||
const raw =
|
||||
'[Working directory: /tmp/workspace]\n\n' +
|
||||
'<role>\nYou are BrowserOS\n</role>\n\n' +
|
||||
'<user_request>\nopen google.com\n</user_request>\n\n' +
|
||||
'<system-reminder>\nA reminder the user never typed.\n</system-reminder>'
|
||||
expect(cleanHistoryUserText(raw)).toBe('open google.com')
|
||||
'[Working directory: /Users/me/.browseros-dev/agents/harness/workspace]\n\n' +
|
||||
`${ROLE_BLOCK}\n\n<user_request>\nhey\n</user_request>`
|
||||
expect(cleanHistoryUserText(raw)).toBe('hey')
|
||||
})
|
||||
|
||||
it('strips the full OpenClaw acp-cli envelope on image-attachment turns', () => {
|
||||
// OpenClaw 2026.5.4+ (post image-bypass deletion) wraps user
|
||||
// messages with stacked envelope lines:
|
||||
// [media attached: <path> (<mime>)]
|
||||
// [<weekday> <date> <tz>] [Working directory: <path>]
|
||||
// <BrowserOS role envelope>
|
||||
const raw =
|
||||
'[media attached: /home/node/.openclaw/media/inbound/image---abc.png (image/png)]\n' +
|
||||
'[Thu 2026-05-07 02:07 GMT+5:30] [Working directory: /Users/me/.browseros-dev/agents/harness/workspace]\n\n' +
|
||||
`${ROLE_BLOCK}\n\n<user_request>\nWhat color is this?\n</user_request>`
|
||||
expect(cleanHistoryUserText(raw)).toBe('What color is this?')
|
||||
})
|
||||
|
||||
it('strips multiple stacked [media attached:] lines (one per attachment)', () => {
|
||||
const raw =
|
||||
'[media attached: /home/node/.openclaw/media/inbound/image---a.png (image/png)]\n' +
|
||||
'[media attached: /home/node/.openclaw/media/inbound/image---b.jpg (image/jpeg)]\n' +
|
||||
'[Thu 2026-05-07 02:07 GMT+5:30] [Working directory: /workspace]\n\n' +
|
||||
`${ROLE_BLOCK}\n\n<user_request>\nCompare these.\n</user_request>`
|
||||
expect(cleanHistoryUserText(raw)).toBe('Compare these.')
|
||||
})
|
||||
|
||||
it('splits queued-marker concatenations and cleans each chunk', () => {
|
||||
|
||||
@@ -14,10 +14,10 @@ describe('OpenClawHttpClient', () => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
it('checks gateway authentication with the current bearer token', async () => {
|
||||
it('checks no-auth gateway availability without an Authorization header', async () => {
|
||||
const fetchMock = mock(() => Promise.resolve(new Response('{}')))
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await expect(client.isAuthenticated()).resolves.toBe(true)
|
||||
|
||||
@@ -26,17 +26,15 @@ describe('OpenClawHttpClient', () => {
|
||||
)
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Bearer gateway-token',
|
||||
},
|
||||
})
|
||||
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
|
||||
})
|
||||
|
||||
it('treats rejected gateway authentication as unavailable', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('Unauthorized', { status: 401 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await expect(client.isAuthenticated()).resolves.toBe(false)
|
||||
})
|
||||
@@ -45,13 +43,13 @@ describe('OpenClawHttpClient', () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.reject(new Error('connect ECONNREFUSED')),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await expect(client.isAuthenticated()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
describe('getSessionHistory', () => {
|
||||
it('sends GET with bearer auth and forwards limit/cursor as query params', async () => {
|
||||
it('sends GET and forwards limit/cursor as query params', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
@@ -69,7 +67,7 @@ describe('OpenClawHttpClient', () => {
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
const result = await client.getSessionHistory('agent:main:main', {
|
||||
limit: 50,
|
||||
@@ -79,10 +77,8 @@ describe('OpenClawHttpClient', () => {
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'http://127.0.0.1:18789/sessions/agent%3Amain%3Amain/history?limit=50&cursor=abc',
|
||||
)
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer gateway-token' },
|
||||
})
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ method: 'GET' })
|
||||
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
|
||||
expect(result).toEqual({
|
||||
sessionKey: 'agent:main:main',
|
||||
messages: [
|
||||
@@ -94,6 +90,25 @@ describe('OpenClawHttpClient', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('sends no Authorization header when no token provider is configured', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ sessionKey: 'k', messages: [] }), {
|
||||
status: 200,
|
||||
}),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await client.getSessionHistory('k')
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
})
|
||||
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
|
||||
})
|
||||
|
||||
it('omits limit and cursor from the query when undefined', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
@@ -103,7 +118,7 @@ describe('OpenClawHttpClient', () => {
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await client.getSessionHistory('k')
|
||||
|
||||
@@ -116,7 +131,7 @@ describe('OpenClawHttpClient', () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('not found', { status: 404 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await expect(
|
||||
client.getSessionHistory('missing-key'),
|
||||
@@ -127,7 +142,7 @@ describe('OpenClawHttpClient', () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('boom', { status: 500 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await expect(client.getSessionHistory('k')).rejects.toThrow('boom')
|
||||
})
|
||||
@@ -142,7 +157,7 @@ describe('OpenClawHttpClient', () => {
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const controller = new AbortController()
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await client.getSessionHistory('k', { signal: controller.signal })
|
||||
|
||||
@@ -179,7 +194,7 @@ describe('OpenClawHttpClient', () => {
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
const stream = await client.streamSessionHistory('k', { limit: 20 })
|
||||
|
||||
@@ -189,11 +204,9 @@ describe('OpenClawHttpClient', () => {
|
||||
)
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
Authorization: 'Bearer gateway-token',
|
||||
},
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
})
|
||||
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: 'history',
|
||||
@@ -215,6 +228,33 @@ describe('OpenClawHttpClient', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps SSE Accept without Authorization when no token provider is configured', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await client.streamSessionHistory('k')
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
})
|
||||
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
|
||||
})
|
||||
|
||||
it('forwards upstream error frames and closes', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
@@ -234,7 +274,7 @@ describe('OpenClawHttpClient', () => {
|
||||
),
|
||||
),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
const stream = await client.streamSessionHistory('k')
|
||||
|
||||
@@ -247,7 +287,7 @@ describe('OpenClawHttpClient', () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('not found', { status: 404 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
await expect(client.streamSessionHistory('k')).rejects.toBeInstanceOf(
|
||||
OpenClawSessionNotFoundError,
|
||||
@@ -284,7 +324,7 @@ describe('OpenClawHttpClient', () => {
|
||||
),
|
||||
),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
const client = new OpenClawHttpClient(18789)
|
||||
|
||||
const stream = await client.streamSessionHistory('k', {
|
||||
signal: ac.signal,
|
||||
@@ -315,3 +355,10 @@ async function readEvents(
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
function fetchHeaders(
|
||||
fetchMock: ReturnType<typeof mock>,
|
||||
): Record<string, string> {
|
||||
return ((fetchMock.mock.calls[0]?.[1] as RequestInit | undefined)?.headers ??
|
||||
{}) as Record<string, string>
|
||||
}
|
||||
|
||||
@@ -338,7 +338,7 @@ describe('OpenClawService', () => {
|
||||
expect(runOnboard).toHaveBeenCalledWith({
|
||||
acceptRisk: true,
|
||||
authChoice: 'skip',
|
||||
gatewayAuth: 'token',
|
||||
gatewayAuth: 'none',
|
||||
gatewayBind: 'lan',
|
||||
gatewayPort: 18789,
|
||||
installDaemon: false,
|
||||
@@ -377,7 +377,6 @@ describe('OpenClawService', () => {
|
||||
hostPort: expect.any(Number),
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: undefined,
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
@@ -434,66 +433,6 @@ describe('OpenClawService', () => {
|
||||
expect(restartGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads the persisted gateway token from the mounted config before control plane calls', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: 'cli-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.token = 'random-token'
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
}
|
||||
service.cliClient = {
|
||||
listAgents: mock(async () => {
|
||||
expect(service.token).toBe('cli-token')
|
||||
return []
|
||||
}),
|
||||
}
|
||||
|
||||
await service.listAgents()
|
||||
})
|
||||
|
||||
it('caches the loaded gateway token from config across steady-state control plane calls', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: 'cli-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const listAgents = mock(async () => [])
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
}
|
||||
service.cliClient = {
|
||||
listAgents,
|
||||
}
|
||||
|
||||
await service.listAgents()
|
||||
await service.listAgents()
|
||||
|
||||
expect(listAgents).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('writes provider credentials into the mounted state env file during setup', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
@@ -672,7 +611,6 @@ describe('OpenClawService', () => {
|
||||
hostPort: expect.any(Number),
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: 'cli-token',
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
@@ -887,7 +825,6 @@ describe('OpenClawService', () => {
|
||||
hostPort: expect.any(Number),
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: 'cli-token',
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
@@ -1096,7 +1033,6 @@ describe('OpenClawService', () => {
|
||||
hostPort: expect.any(Number),
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: 'cli-token',
|
||||
}),
|
||||
)
|
||||
expect(waitForReady).toHaveBeenCalledTimes(1)
|
||||
@@ -1136,6 +1072,53 @@ describe('OpenClawService', () => {
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('tryAutoStart reuses a ready no-auth gateway without Authorization', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: 'none',
|
||||
token: 'stale-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const ensureReady = mock(async () => {})
|
||||
const isReady = mock(async () => true)
|
||||
const isGatewayCurrent = mock(async () => true)
|
||||
const startGateway = mock(async () => {})
|
||||
const probe = mock(async () => {})
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(new Response('', { status: 200 })),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady,
|
||||
isGatewayCurrent,
|
||||
startGateway,
|
||||
}
|
||||
service.cliClient = { probe }
|
||||
|
||||
await service.tryAutoStart()
|
||||
|
||||
expect(startGateway).not.toHaveBeenCalled()
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'http://127.0.0.1:18789/v1/models',
|
||||
)
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
})
|
||||
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('tryAutoStart recreates a ready gateway when the image is stale', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
@@ -1720,3 +1703,10 @@ function mockGatewayAuth(status = 200): ReturnType<typeof mock> {
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
function fetchHeaders(
|
||||
fetchMock: ReturnType<typeof mock>,
|
||||
): Record<string, string> {
|
||||
return ((fetchMock.mock.calls[0]?.[1] as RequestInit | undefined)?.headers ??
|
||||
{}) as Record<string, string>
|
||||
}
|
||||
|
||||
@@ -110,4 +110,33 @@ describe('prepareAcpxAgentContext', () => {
|
||||
expect(prepared.runPrompt).not.toContain('AGENT_HOME/MEMORY.md')
|
||||
expect(prepared.runPrompt).not.toContain('Available skills:')
|
||||
})
|
||||
|
||||
it('prepares Hermes with HERMES_HOME pointing at the in-container agent home (translated from the host path)', async () => {
|
||||
const browserosDir = await mkdtemp(join(tmpdir(), 'browseros-adapters-'))
|
||||
tempDirs.push(browserosDir)
|
||||
const prepared = await prepareAcpxAgentContext({
|
||||
browserosDir,
|
||||
agent: makeAgent('hermes'),
|
||||
sessionId: 'main',
|
||||
sessionKey: 'agent:hermes-agent:main',
|
||||
cwdOverride: null,
|
||||
isSelectedCwd: false,
|
||||
message: 'remember this',
|
||||
})
|
||||
|
||||
// HERMES_HOME must be the *container-side* path (under /data) so the
|
||||
// hermes binary running inside the container can actually open it.
|
||||
// The host-side seeded files are reachable via the bind mount.
|
||||
expect(prepared.commandEnv.HERMES_HOME).toBe(
|
||||
'/data/agents/harness/hermes-agent/home',
|
||||
)
|
||||
expect(prepared.commandEnv).not.toHaveProperty('AGENT_HOME')
|
||||
expect(prepared.commandEnv).not.toHaveProperty('CODEX_HOME')
|
||||
expect(prepared.commandEnv).not.toHaveProperty('CLAUDE_CONFIG_DIR')
|
||||
expect(prepared.useBrowserosMcp).toBe(true)
|
||||
expect(prepared.openclawSessionKey).toBeNull()
|
||||
expect(prepared.runtimeSessionKey).toMatch(
|
||||
/^agent:hermes-agent:main:[a-f0-9]{16}$/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -955,6 +955,99 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
expect(command).toContain('/runtime/codex-home')
|
||||
})
|
||||
|
||||
it('resolves the Hermes adapter to a container `nerdctl exec hermes acp` command when a HermesGatewayAccessor is wired', async () => {
|
||||
const browserosDir = await mkdtemp(
|
||||
join(tmpdir(), 'browseros-acpx-browseros-'),
|
||||
)
|
||||
const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-'))
|
||||
tempDirs.push(browserosDir, stateDir)
|
||||
const calls: Array<{ method: string; input: unknown }> = []
|
||||
const runtime = new AcpxRuntime({
|
||||
browserosDir,
|
||||
stateDir,
|
||||
hermesGateway: {
|
||||
getContainerName: () => 'browseros-hermes-hermes-agent-1',
|
||||
getLimaHomeDir: () => '/Users/dev/.browseros-dev/lima',
|
||||
getLimactlPath: () => '/opt/homebrew/bin/limactl',
|
||||
getVmName: () => 'browseros-vm',
|
||||
},
|
||||
runtimeFactory: (options) => {
|
||||
calls.push({ method: 'createRuntime', input: options })
|
||||
return createFakeAcpRuntime(calls)
|
||||
},
|
||||
})
|
||||
const agent = makeAgent({ id: 'agent-1', adapter: 'hermes' })
|
||||
|
||||
await collectStream(
|
||||
await runtime.send({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
sessionKey: agent.sessionKey,
|
||||
message: 'hi',
|
||||
permissionMode: 'approve-all',
|
||||
}),
|
||||
)
|
||||
|
||||
const command =
|
||||
getCreateRuntimeOptions(calls).agentRegistry.resolve('hermes')
|
||||
// Container-spawn path uses limactl shell + nerdctl exec; no host-
|
||||
// process bash/tee workaround (those were Phase A only).
|
||||
expect(command).toContain('env LIMA_HOME=/Users/dev/.browseros-dev/lima')
|
||||
expect(command).toContain(
|
||||
'/opt/homebrew/bin/limactl shell --workdir / browseros-vm --',
|
||||
)
|
||||
expect(command).toContain('nerdctl exec -i')
|
||||
expect(command).toContain('browseros-hermes-hermes-agent-1')
|
||||
expect(command).toContain('hermes acp')
|
||||
expect(command).toContain('HERMES_HOME=')
|
||||
expect(command).not.toContain('bash -c')
|
||||
expect(command).not.toContain('tee /dev/null')
|
||||
expect(command).not.toContain('AGENT_HOME=')
|
||||
expect(command).not.toContain('CODEX_HOME=')
|
||||
expect(command).not.toContain('CLAUDE_CONFIG_DIR=')
|
||||
})
|
||||
|
||||
it('falls back to a host-process `hermes acp` command when no HermesGatewayAccessor is wired', async () => {
|
||||
const browserosDir = await mkdtemp(
|
||||
join(tmpdir(), 'browseros-acpx-browseros-'),
|
||||
)
|
||||
const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-'))
|
||||
tempDirs.push(browserosDir, stateDir)
|
||||
const calls: Array<{ method: string; input: unknown }> = []
|
||||
const runtime = new AcpxRuntime({
|
||||
browserosDir,
|
||||
stateDir,
|
||||
runtimeFactory: (options) => {
|
||||
calls.push({ method: 'createRuntime', input: options })
|
||||
return createFakeAcpRuntime(calls)
|
||||
},
|
||||
})
|
||||
const agent = makeAgent({ id: 'agent-1', adapter: 'hermes' })
|
||||
|
||||
await collectStream(
|
||||
await runtime.send({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
sessionKey: agent.sessionKey,
|
||||
message: 'hi',
|
||||
permissionMode: 'approve-all',
|
||||
}),
|
||||
)
|
||||
|
||||
const command =
|
||||
getCreateRuntimeOptions(calls).agentRegistry.resolve('hermes')
|
||||
// Host-process fallback: bare `hermes acp` with HERMES_HOME injected
|
||||
// via wrapCommandWithEnv. No limactl/nerdctl chain — used by tests
|
||||
// and as a defensive escape hatch when the container service hasn't
|
||||
// been wired yet.
|
||||
expect(command).toContain('hermes acp')
|
||||
expect(command).toContain('env HERMES_HOME=')
|
||||
expect(command).not.toContain('limactl')
|
||||
expect(command).not.toContain('nerdctl')
|
||||
expect(command).not.toContain('bash -c')
|
||||
expect(command).not.toContain('tee /dev/null')
|
||||
})
|
||||
|
||||
it('does not reuse an Acpx runtime across different command identities', async () => {
|
||||
const browserosDir = await mkdtemp(
|
||||
join(tmpdir(), 'browseros-acpx-browseros-'),
|
||||
@@ -1043,9 +1136,8 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
expect(command).toContain(
|
||||
'nerdctl exec -i -e OPENCLAW_HIDE_BANNER=1 -e OPENCLAW_SUPPRESS_NOTES=1 browseros-openclaw-openclaw-gateway-1',
|
||||
)
|
||||
expect(command).toContain(
|
||||
'openclaw acp --url ws://127.0.0.1:18789 --token test-token-abc',
|
||||
)
|
||||
expect(command).toContain('openclaw acp --url ws://127.0.0.1:18789')
|
||||
expect(command).not.toContain('--token')
|
||||
// sessionKey routing: the bridge needs --session <key> to map newSession
|
||||
// requests to the matching gateway agent (acpx does not forward
|
||||
// sessionKey via ACP newSession params).
|
||||
@@ -1253,142 +1345,6 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
|
||||
.map((call) => (call.input as { timeoutMs?: number }).timeoutMs),
|
||||
).toEqual([1_000, 2_000])
|
||||
})
|
||||
|
||||
it('diverts OpenClaw image turns to the gateway chat client and persists them to the session record', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'browseros-acpx-runtime-'))
|
||||
const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-'))
|
||||
tempDirs.push(cwd, stateDir)
|
||||
// Pre-seed the session record so persistence has somewhere to land.
|
||||
// (First-turn-image-only sessions deliberately skip persistence; that
|
||||
// path is covered by the empty-record test below.)
|
||||
const sessionStore = createRuntimeStore({ stateDir })
|
||||
const seedTimestamp = '2026-04-28T20:00:00.000Z'
|
||||
const seedRecord: AcpSessionRecord = {
|
||||
schema: 'acpx.session.v1',
|
||||
acpxRecordId: 'agent:img-bot:main',
|
||||
acpSessionId: 'sid-img',
|
||||
agentSessionId: 'inner-img',
|
||||
agentCommand: 'env LIMA_HOME=/tmp limactl shell vm -- nerdctl exec',
|
||||
cwd,
|
||||
name: 'agent:img-bot:main',
|
||||
createdAt: seedTimestamp,
|
||||
lastUsedAt: seedTimestamp,
|
||||
lastSeq: 0,
|
||||
eventLog: {
|
||||
active_path: '',
|
||||
segment_count: 0,
|
||||
max_segment_bytes: 0,
|
||||
max_segments: 0,
|
||||
},
|
||||
closed: false,
|
||||
messages: [
|
||||
{
|
||||
User: {
|
||||
id: 'prior-user',
|
||||
content: [{ Text: 'literal & <tag>' } as never],
|
||||
},
|
||||
},
|
||||
{ Agent: { content: [{ Text: 'Prior answer.' }], tool_results: {} } },
|
||||
],
|
||||
updated_at: seedTimestamp,
|
||||
cumulative_token_usage: {},
|
||||
request_token_usage: {},
|
||||
acpx: {},
|
||||
}
|
||||
await sessionStore.save(seedRecord)
|
||||
|
||||
const gatewayCalls: Array<{ method: string; input: unknown }> = []
|
||||
const openclawGatewayChat = {
|
||||
streamTurn: async (input: unknown) => {
|
||||
gatewayCalls.push({ method: 'streamTurn', input })
|
||||
return new ReadableStream<AgentStreamEvent>({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: 'text_delta',
|
||||
text: 'Red.',
|
||||
stream: 'output',
|
||||
})
|
||||
controller.enqueue({ type: 'done', stopReason: 'end_turn' })
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
},
|
||||
} as never
|
||||
const calls: Array<{ method: string; input: unknown }> = []
|
||||
const runtime = new AcpxRuntime({
|
||||
cwd,
|
||||
stateDir,
|
||||
openclawGatewayChat,
|
||||
// Provide a runtime factory that would fail loudly if reached —
|
||||
// image turns must NOT fall through to the ACP path.
|
||||
runtimeFactory: (options) => {
|
||||
calls.push({ method: 'createRuntime', input: options })
|
||||
throw new Error('ACP path should not be reached for image turns')
|
||||
},
|
||||
})
|
||||
|
||||
const agent: AgentDefinition = {
|
||||
id: 'img-bot',
|
||||
name: 'OpenClaw image bot',
|
||||
adapter: 'openclaw',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: 'agent:img-bot:main',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
}
|
||||
|
||||
const events = await collectStream(
|
||||
await runtime.send({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
sessionKey: agent.sessionKey,
|
||||
message: 'What color is this?',
|
||||
attachments: [{ mediaType: 'image/png', data: 'BASE64DATA' }],
|
||||
permissionMode: 'approve-all',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: 'text_delta', text: 'Red.', stream: 'output' },
|
||||
{ type: 'done', stopReason: 'end_turn' },
|
||||
])
|
||||
expect(gatewayCalls).toHaveLength(1)
|
||||
expect(
|
||||
calls.filter((call) => call.method === 'createRuntime'),
|
||||
).toHaveLength(0)
|
||||
const gatewayInput = gatewayCalls[0]?.input as {
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
messages: Array<{
|
||||
role: string
|
||||
content: string | Array<{ type: string }>
|
||||
}>
|
||||
}
|
||||
expect(gatewayInput.agentId).toBe('img-bot')
|
||||
expect(gatewayInput.messages[0]).toEqual({
|
||||
role: 'user',
|
||||
content: 'literal & <tag>',
|
||||
})
|
||||
expect(gatewayInput.messages.at(-1)?.role).toBe('user')
|
||||
const userContent = gatewayInput.messages.at(-1)?.content
|
||||
expect(Array.isArray(userContent)).toBe(true)
|
||||
expect(
|
||||
(userContent as Array<{ type: string }>).filter(
|
||||
(p) => p.type === 'image_url',
|
||||
),
|
||||
).toHaveLength(1)
|
||||
|
||||
// Persistence check: history should now show the user+assistant turn.
|
||||
const history = await runtime.getHistory({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
})
|
||||
expect(history.items.slice(-2).map((item) => item.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
])
|
||||
expect(history.items.at(-1)?.text).toBe('Red.')
|
||||
})
|
||||
})
|
||||
|
||||
function makeAgent(input: {
|
||||
|
||||
@@ -12,11 +12,12 @@ import {
|
||||
} from '../../../src/lib/agents/agent-catalog'
|
||||
|
||||
describe('AGENT_ADAPTER_CATALOG', () => {
|
||||
it('exposes Claude, Codex, and OpenClaw adapters with model and effort options', () => {
|
||||
it('exposes Claude, Codex, OpenClaw, and Hermes adapters with model and effort options', () => {
|
||||
expect(AGENT_ADAPTER_CATALOG.map((adapter) => adapter.id)).toEqual([
|
||||
'claude',
|
||||
'codex',
|
||||
'openclaw',
|
||||
'hermes',
|
||||
])
|
||||
|
||||
expect(getAgentAdapterDescriptor('claude')).toMatchObject({
|
||||
|
||||
@@ -121,6 +121,9 @@ async function setupApplicationTest() {
|
||||
const openclawService = await import(
|
||||
'../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const hermesContainerService = await import(
|
||||
'../src/api/services/hermes/hermes-container'
|
||||
)
|
||||
const browserosDir = await import('../src/lib/browseros-dir')
|
||||
const dbModule = await import('../src/lib/db')
|
||||
const identityModule = await import('../src/lib/identity')
|
||||
@@ -206,6 +209,27 @@ async function setupApplicationTest() {
|
||||
}) as never,
|
||||
)
|
||||
|
||||
const hermesPrewarm = mock(async () => {})
|
||||
const hermesStart = mock(async () => {})
|
||||
spyOn(
|
||||
hermesContainerService,
|
||||
'configureHermesContainerService',
|
||||
).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
prewarm: hermesPrewarm,
|
||||
start: hermesStart,
|
||||
}) as never,
|
||||
)
|
||||
spyOn(hermesContainerService, 'getHermesContainerService').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
prewarm: hermesPrewarm,
|
||||
start: hermesStart,
|
||||
shutdown: async () => {},
|
||||
}) as never,
|
||||
)
|
||||
|
||||
const { Application } = await import('../src/main')
|
||||
return {
|
||||
Application,
|
||||
@@ -217,5 +241,6 @@ async function setupApplicationTest() {
|
||||
loggerWarn,
|
||||
initializeDb,
|
||||
openClawService: { prewarm, tryAutoStart },
|
||||
hermesService: { prewarm: hermesPrewarm, start: hermesStart },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
"types": "./src/constants/openclaw.ts",
|
||||
"default": "./src/constants/openclaw.ts"
|
||||
},
|
||||
"./constants/hermes": {
|
||||
"types": "./src/constants/hermes.ts",
|
||||
"default": "./src/constants/hermes.ts"
|
||||
},
|
||||
"./constants/tool-approval": {
|
||||
"types": "./src/constants/tool-approval.ts",
|
||||
"default": "./src/constants/tool-approval.ts"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export const HERMES_AGENT_NAME = 'hermes'
|
||||
export const HERMES_IMAGE = 'docker.io/nousresearch/hermes-agent:v2026.4.30'
|
||||
export const HERMES_COMPOSE_PROJECT_NAME = 'browseros-hermes'
|
||||
export const HERMES_CONTAINER_NAME = `${HERMES_COMPOSE_PROJECT_NAME}-hermes-agent-1`
|
||||
// Inside the container, /data is the volume mount where per-agent HERMES_HOME
|
||||
// directories live: /data/agents/harness/<agentId>/home. The host-side
|
||||
// directory that backs this mount lives under the BrowserOS-managed VM
|
||||
// state directory (so it's reachable inside the Lima VM via the existing
|
||||
// vm/ mount); the container sees the same files via /data/agents/harness.
|
||||
export const HERMES_CONTAINER_DATA_DIR = '/data'
|
||||
export const HERMES_CONTAINER_HARNESS_DIR = `${HERMES_CONTAINER_DATA_DIR}/agents/harness`
|
||||
|
||||
/**
|
||||
* BrowserOS LLM provider types Hermes can consume. The frontend filters
|
||||
* the global provider list to these; the backend `hermes-provider-map`
|
||||
* maps them onto Hermes' own provider keys. Keep both sides in sync via
|
||||
* this single list — adding a new entry without updating the backend
|
||||
* map will cause a 400 at agent-create time.
|
||||
*
|
||||
* Bedrock is intentionally NOT included yet — it needs multiple env
|
||||
* vars (AWS_ACCESS_KEY_ID + secret + region) and a separate UX path.
|
||||
*/
|
||||
export const HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES = [
|
||||
'anthropic',
|
||||
'openai',
|
||||
'openai-compatible',
|
||||
'openrouter',
|
||||
] as const
|
||||
|
||||
export type HermesSupportedBrowserosProviderType =
|
||||
(typeof HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES)[number]
|
||||
@@ -1,5 +1,6 @@
|
||||
export const OPENCLAW_AGENT_NAME = 'openclaw'
|
||||
export const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:2026.4.12'
|
||||
export const OPENCLAW_IMAGE =
|
||||
'ghcr.io/browseros-ai/openclaw:2026.5.4-browseros.1'
|
||||
export const OPENCLAW_GATEWAY_CONTAINER_PORT = 18789
|
||||
export const OPENCLAW_CONTAINER_HOME = '/home/node/.openclaw'
|
||||
export const OPENCLAW_COMPOSE_PROJECT_NAME = 'browseros-openclaw'
|
||||
|
||||
Reference in New Issue
Block a user