refactor(agent): clean up hermes adapter structure (#994)

This commit is contained in:
shivammittal274
2026-05-11 22:57:59 +05:30
committed by GitHub
parent d7e1125db3
commit dad2331448
14 changed files with 537 additions and 336 deletions

View File

@@ -131,15 +131,14 @@ export interface CreateHarnessAgentInput {
modelId?: string
reasoningEffort?: string
/**
* Hermes-only — provider id from `HERMES_SUPPORTED_PROVIDERS`. When
* paired with `apiKey`, the backend writes a per-agent
* config.yaml + .env into the agent's HERMES_HOME so the first chat
* doesn't depend on the user having run `hermes setup` globally.
* Adapter provider id from the user's BrowserOS AI Settings entry.
* Provider-backed adapters use this with `apiKey`/`baseUrl` to write
* or provision their runtime-specific provider config.
*/
providerType?: string
/** Hermes-only — API key paired with `providerType`. */
/** API key paired with `providerType` when the selected adapter needs one. */
apiKey?: string
/** Hermes-only — base URL for the `custom` provider. */
/** Base URL for OpenAI-compatible/custom provider entries. */
baseUrl?: string
}

View File

@@ -19,13 +19,13 @@ import type {
} from '../../../lib/agents/agent-store'
import type { AgentDefinition } from '../../../lib/agents/agent-types'
import { DbAgentStore } from '../../../lib/agents/db-agent-store'
import { writeHermesPerAgentProvider } from '../../../lib/agents/hermes/hermes-paths'
import { getHermesProviderMapping } from '../../../lib/agents/hermes/hermes-provider-map'
import {
FileMessageQueue,
type QueuedMessage,
type QueuedMessageAttachment,
} from '../../../lib/agents/message-queue'
import { writeHermesPerAgentProvider } from '../hermes/hermes-paths'
import { getHermesProviderMapping } from '../hermes/hermes-provider-map'
export {
MessageQueueFullError,
@@ -202,12 +202,6 @@ export class AgentHarnessService {
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
@@ -231,7 +225,6 @@ export class AgentHarnessService {
agentStore?: AgentStore
runtime?: AgentRuntime
browserosServerPort?: number
browserosDir?: string
openclawGateway?: OpenclawGatewayAccessor
openclawProvisioner?: OpenClawProvisioner
turnRegistry?: TurnRegistry
@@ -249,7 +242,6 @@ export class AgentHarnessService {
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
}
@@ -620,7 +612,6 @@ export class AgentHarnessService {
)
}
await writeHermesPerAgentProvider({
browserosDir: this.browserosDir,
agentId,
providerId: mapping.hermesProvider,
envVarName: mapping.envVarName,

View File

@@ -5,7 +5,6 @@
*/
import { join } from 'node:path'
import { OPENCLAW_GATEWAY_CONTAINER_PORT } from '@browseros/shared/constants/openclaw'
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
import {
type AcpRuntimeEvent,
@@ -32,6 +31,10 @@ import type {
AgentHistoryEntry,
AgentHistoryToolCall,
} from './agent-types'
import {
type OpenclawGatewayAccessor,
resolveOpenclawAcpCommand,
} from './openclaw/acp-command'
import { getHermesRuntime } from './runtime'
import type {
AgentHistoryPage,
@@ -43,24 +46,7 @@ import type {
AgentStreamEvent,
} from './types'
/**
* Live-getter access to the OpenClaw gateway runtime info. Required
* when spawning the openclaw ACP adapter inside the gateway container.
*
* Fields are getters (not snapshot values) so the harness picks up the
* current VM/container paths at spawn time. The bundled gateway runs
* with `gateway.auth.mode=none`, so no auth token is plumbed through.
*/
export interface OpenclawGatewayAccessor {
/** Container name e.g. browseros-openclaw-openclaw-gateway-1. */
getContainerName(): string
/** LIMA_HOME directory containing the browseros-vm instance. */
getLimaHomeDir(): string
/** Resolved path to the `limactl` binary (bundled or host). */
getLimactlPath(): string
/** VM name registered in LIMA_HOME (e.g. browseros-vm). */
getVmName(): string
}
export type { OpenclawGatewayAccessor } from './openclaw/acp-command'
type AcpxRuntimeOptions = {
cwd?: string
@@ -736,79 +722,6 @@ function createBrowserosAgentRegistry(input: {
}
}
/**
* Builds the command string acpx will spawn for an `openclaw` adapter.
* Runs `openclaw acp` inside the gateway container via the bundled
* `limactl shell <vm> -- nerdctl exec -i ...` chain so the binary
* already installed alongside the gateway is reused; BrowserOS does
* not require a host-side openclaw install.
*
* Auth: BrowserOS configures the bundled gateway with `gateway.auth.mode=none`,
* so no gateway token flag is needed for the local ACP bridge.
*
* Banner output: OPENCLAW_HIDE_BANNER and OPENCLAW_SUPPRESS_NOTES
* suppress non-JSON-RPC chatter on stdout that would otherwise corrupt
* the ACP message stream.
*/
function resolveOpenclawAcpCommand(
gateway: OpenclawGatewayAccessor,
sessionKey: string | null,
): string {
const limactl = gateway.getLimactlPath()
const vm = gateway.getVmName()
const container = gateway.getContainerName()
const limaHome = gateway.getLimaHomeDir()
const gatewayUrlInsideContainer = `ws://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}`
// `--session <key>` routes the bridge's newSession requests to the
// matching gateway agent. acpx does not pass sessionKey through ACP
// newSession params, so without this CLI flag the bridge falls back
// to a synthetic acp:<uuid> session that does not resolve to any
// provisioned gateway agent.
//
// Harness keys are `agent:<harness-id>:main`; the harness id matches
// a dual-created gateway agent name, so the bridge resolves directly.
// Any legacy non-agent key falls back to the always-provisioned
// `main` gateway agent with the original key encoded as a channel
// suffix.
const bridgeSessionKey = sessionKey
? sessionKey.startsWith('agent:')
? sessionKey
: `agent:main:${sessionKey.replace(/[^a-zA-Z0-9-]/g, '-')}`
: null
//
// Prefix `env LIMA_HOME=<path>` so the spawned limactl finds the
// BrowserOS-owned VM instance. The BrowserOS server doesn't set
// LIMA_HOME on its own process env (it injects per-spawn elsewhere),
// so the acpx-spawned subprocess won't inherit it without this hint.
const argv = [
'env',
`LIMA_HOME=${limaHome}`,
limactl,
'shell',
'--workdir',
'/',
vm,
'--',
'nerdctl',
'exec',
'-i',
'-e',
'OPENCLAW_HIDE_BANNER=1',
'-e',
'OPENCLAW_SUPPRESS_NOTES=1',
container,
'openclaw',
'acp',
'--url',
gatewayUrlInsideContainer,
]
if (bridgeSessionKey) {
argv.push('--session', bridgeSessionKey)
}
return argv.join(' ')
}
async function applyRuntimeControls(
runtime: AcpxCoreRuntime,
handle: AcpRuntimeHandle,

View File

@@ -15,7 +15,7 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { getVmStateDir } from '../../../lib/browseros-dir'
import { getVmStateDir } from '../../browseros-dir'
/** Top-level Hermes state directory: `<browserosDir>/vm/hermes`. */
export function getHermesHostStateDir(browserosDir?: string): string {

View File

@@ -0,0 +1,99 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { OPENCLAW_GATEWAY_CONTAINER_PORT } from '@browseros/shared/constants/openclaw'
/**
* Live-getter access to the OpenClaw gateway runtime info. Required
* when spawning the OpenClaw ACP adapter inside the gateway container.
*
* Fields are getters (not snapshot values) so the harness picks up the
* current VM/container paths at spawn time. The bundled gateway runs
* with `gateway.auth.mode=none`, so no auth token is plumbed through.
*/
export interface OpenclawGatewayAccessor {
/** Container name e.g. browseros-openclaw-openclaw-gateway-1. */
getContainerName(): string
/** LIMA_HOME directory containing the browseros-vm instance. */
getLimaHomeDir(): string
/** Resolved path to the `limactl` binary (bundled or host). */
getLimactlPath(): string
/** VM name registered in LIMA_HOME (e.g. browseros-vm). */
getVmName(): string
}
/**
* Builds the command string acpx will spawn for an `openclaw` adapter.
* Runs `openclaw acp` inside the gateway container via the bundled
* `limactl shell <vm> -- nerdctl exec -i ...` chain so the binary
* already installed alongside the gateway is reused; BrowserOS does
* not require a host-side OpenClaw install.
*
* Auth: BrowserOS configures the bundled gateway with `gateway.auth.mode=none`,
* so no gateway token flag is needed for the local ACP bridge.
*
* Banner output: OPENCLAW_HIDE_BANNER and OPENCLAW_SUPPRESS_NOTES
* suppress non-JSON-RPC chatter on stdout that would otherwise corrupt
* the ACP message stream.
*/
export function resolveOpenclawAcpCommand(
gateway: OpenclawGatewayAccessor,
sessionKey: string | null,
): string {
const limactl = gateway.getLimactlPath()
const vm = gateway.getVmName()
const container = gateway.getContainerName()
const limaHome = gateway.getLimaHomeDir()
const gatewayUrlInsideContainer = `ws://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}`
// `--session <key>` routes the bridge's newSession requests to the
// matching gateway agent. acpx does not pass sessionKey through ACP
// newSession params, so without this CLI flag the bridge falls back
// to a synthetic acp:<uuid> session that does not resolve to any
// provisioned gateway agent.
//
// Harness keys are `agent:<harness-id>:main`; the harness id matches
// a dual-created gateway agent name, so the bridge resolves directly.
// Any legacy non-agent key falls back to the always-provisioned
// `main` gateway agent with the original key encoded as a channel
// suffix.
const bridgeSessionKey = sessionKey
? sessionKey.startsWith('agent:')
? sessionKey
: `agent:main:${sessionKey.replace(/[^a-zA-Z0-9-]/g, '-')}`
: null
// Prefix `env LIMA_HOME=<path>` so the spawned limactl finds the
// BrowserOS-owned VM instance. The BrowserOS server doesn't set
// LIMA_HOME on its own process env (it injects per-spawn elsewhere),
// so the acpx-spawned subprocess won't inherit it without this hint.
const argv = [
'env',
`LIMA_HOME=${limaHome}`,
limactl,
'shell',
'--workdir',
'/',
vm,
'--',
'nerdctl',
'exec',
'-i',
'-e',
'OPENCLAW_HIDE_BANNER=1',
'-e',
'OPENCLAW_SUPPRESS_NOTES=1',
container,
'openclaw',
'acp',
'--url',
gatewayUrlInsideContainer,
]
if (bridgeSessionKey) {
argv.push('--session', bridgeSessionKey)
}
return argv.join(' ')
}

View File

@@ -15,11 +15,6 @@ import {
HERMES_CONTAINER_NAME,
HERMES_IMAGE,
} from '@browseros/shared/constants/hermes'
import {
getHermesAgentHomeHostDir,
getHermesHarnessHostDir,
getHermesHostStateDir,
} from '../../../api/services/hermes/hermes-paths'
import { getBrowserosDir } from '../../browseros-dir'
import { ContainerCli } from '../../container/container-cli'
import { ImageLoader } from '../../container/image-loader'
@@ -46,6 +41,11 @@ import {
finishBrowserosManagedContext,
prepareBrowserosManagedContext,
} from '../acpx-agent-common'
import {
getHermesAgentHomeHostDir,
getHermesHarnessHostDir,
getHermesHostStateDir,
} from '../hermes/hermes-paths'
import { ContainerAgentRuntime } from './container-agent-runtime'
import { getAgentRuntimeRegistry } from './registry'
import type { ExecSpec } from './types'
@@ -53,8 +53,6 @@ import type { ExecSpec } from './types'
const HERMES_BINARY = '/opt/hermes/.venv/bin/hermes'
export interface HermesContainerRuntimeConfig {
/** BrowserOS state root — used to compute per-agent home paths. */
browserosDir: string
/** Host-side directory where Hermes per-agent home dirs live. */
hermesHarnessHostDir: string
}
@@ -150,10 +148,7 @@ export class HermesContainerRuntime extends ContainerAgentRuntime {
// ── AgentRuntime additions ───────────────────────────────────────
getPerAgentHomeDir(agentId: string): string {
return getHermesAgentHomeHostDir({
browserosDir: this.hermesConfig.browserosDir,
agentId,
})
return join(this.hermesConfig.hermesHarnessHostDir, agentId, 'home')
}
/**
@@ -191,9 +186,8 @@ export class HermesContainerRuntime extends ContainerAgentRuntime {
*/
function translateHermesHomeToContainerPath(
hostHome: string,
browserosDir: string,
harnessHostRoot: string,
): string {
const harnessHostRoot = getHermesHarnessHostDir(browserosDir)
if (hostHome === harnessHostRoot) return HERMES_CONTAINER_HARNESS_DIR
if (hostHome.startsWith(`${harnessHostRoot}/`)) {
return `${HERMES_CONTAINER_HARNESS_DIR}${hostHome.slice(harnessHostRoot.length)}`
@@ -232,7 +226,7 @@ export async function prepareHermesContext(
const hermesAgentHomeInContainer = translateHermesHomeToContainerPath(
hermesAgentHome,
input.browserosDir,
getHermesHarnessHostDir(input.browserosDir),
)
return finishBrowserosManagedContext({
@@ -260,6 +254,16 @@ export interface ConfigureHermesRuntimeOptions {
browserosDir?: string
}
export type HermesRuntimeStartupPhase = 'configure' | 'install' | 'start'
export interface StartHermesRuntimeBestEffortOptions
extends ConfigureHermesRuntimeOptions {
configureRuntime?: (
options: ConfigureHermesRuntimeOptions,
) => HermesContainerRuntime | null
onError?: (phase: HermesRuntimeStartupPhase, error: unknown) => void
}
/**
* Build a `HermesContainerRuntime` with production deps (bundled
* limactl, BrowserOS state dirs, Lima VM runtime) and register it in
@@ -310,7 +314,7 @@ export function configureHermesRuntime(
vmName: VM_NAME,
lockDir: join(hermesStateDir, '.locks'),
},
{ browserosDir, hermesHarnessHostDir },
{ hermesHarnessHostDir },
)
getAgentRuntimeRegistry().register(runtime)
@@ -318,8 +322,55 @@ export function configureHermesRuntime(
return runtime
}
/**
* Startup wiring for the Hermes adapter. Kept beside the adapter runtime so
* the server entry point does not need to know Hermes' install/start sequence.
*/
export function startHermesRuntimeBestEffort(
options: StartHermesRuntimeBestEffortOptions = {},
): HermesContainerRuntime | null {
const {
configureRuntime = configureHermesRuntime,
onError = logHermesStartupError,
...configureOptions
} = options
let runtime: HermesContainerRuntime | null
try {
runtime = configureRuntime(configureOptions)
} catch (err) {
onError('configure', err)
return null
}
if (!runtime) return null
void runtime
.executeAction({ type: 'install' })
.catch((err) => onError('install', err))
void runtime
.executeAction({ type: 'start' })
.catch((err) => onError('start', err))
return runtime
}
/** Convenience getter — returns the registered runtime or null. */
export function getHermesRuntime(): HermesContainerRuntime | null {
const r = getAgentRuntimeRegistry().get('hermes')
return r instanceof HermesContainerRuntime ? r : null
}
function logHermesStartupError(
phase: HermesRuntimeStartupPhase,
error: unknown,
): void {
const message =
phase === 'configure'
? 'Hermes container configuration failed, continuing without it'
: phase === 'install'
? 'Hermes prewarm failed'
: 'Hermes container start failed'
logger.warn(message, {
error: error instanceof Error ? error.message : String(error),
})
}

View File

@@ -30,6 +30,8 @@ export {
HermesContainerRuntime,
type HermesContainerRuntimeConfig,
prepareHermesContext,
type StartHermesRuntimeBestEffortOptions,
startHermesRuntimeBestEffort,
} from './hermes-container-runtime'
export {
HostProcessAgentRuntime,

View File

@@ -24,8 +24,8 @@ import { INLINED_ENV } from './env'
import {
configureClaudeRuntime,
configureCodexRuntime,
configureHermesRuntime,
getHermesRuntime,
startHermesRuntimeBestEffort,
} from './lib/agents/runtime'
import {
cleanOldSessions,
@@ -158,32 +158,7 @@ export class Application {
})
}
// Hermes container is also best-effort — same crash isolation
// semantics as OpenClaw above. Image is pulled in the background;
// an idle container is brought up so per-turn `nerdctl exec hermes acp`
// calls from the harness don't pay container-create latency.
try {
const hermesRuntime = configureHermesRuntime({ resourcesDir })
if (hermesRuntime) {
void hermesRuntime.executeAction({ type: 'install' }).catch((err) =>
logger.warn('Hermes prewarm failed', {
error: err instanceof Error ? err.message : String(err),
}),
)
void hermesRuntime.executeAction({ type: 'start' }).catch((err) =>
logger.warn('Hermes container start failed', {
error: err instanceof Error ? err.message : String(err),
}),
)
}
} catch (err) {
logger.warn(
'Hermes container configuration failed, continuing without it',
{
error: err instanceof Error ? err.message : String(err),
},
)
}
startHermesRuntimeBestEffort({ resourcesDir })
metrics.log('http_server.started', { version: VERSION })
}

View File

@@ -5,6 +5,7 @@
import { describe, expect, it } from 'bun:test'
import { mkdtempSync, readFileSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { AgentHarnessService } from '../../../../src/api/services/agents/agent-harness-service'
@@ -445,205 +446,184 @@ describe('AgentHarnessService', () => {
})
it('writes a per-agent Hermes config.yaml + .env when adapter=hermes and provider config complete', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
const agent = await service.createAgent({
name: 'Hermes bot',
adapter: 'hermes',
providerType: 'openrouter',
apiKey: 'sk-or-v1-test-key',
modelId: 'anthropic/claude-haiku-4.5',
})
const homeDir = join(
browserosDir,
'vm',
'hermes',
'harness',
agent.id,
'home',
)
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
const env = readFileSync(join(homeDir, '.env'), 'utf8')
expect(yaml).toContain('"openrouter"')
expect(yaml).toContain('"anthropic/claude-haiku-4.5"')
expect(env).toContain('OPENROUTER_API_KEY=sk-or-v1-test-key')
})
it('rejects Hermes agent creation when apiKey is missing', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
await expect(
service.createAgent({
name: 'Hermes bot',
adapter: 'hermes',
providerType: 'openrouter',
modelId: 'anthropic/claude-haiku-4.5',
}),
).rejects.toThrow(/apiKey/i)
expect(agents).toHaveLength(0)
})
it('rejects Hermes agent creation when providerType is missing', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
await expect(
service.createAgent({ name: 'Hermes bot', adapter: 'hermes' }),
).rejects.toThrow(/providerType/i)
expect(agents).toHaveLength(0)
})
it('rejects Hermes agent creation when modelId is missing', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
await expect(
service.createAgent({
await withHermesBrowserosDir(async ({ browserosDir, service }) => {
const agent = await service.createAgent({
name: 'Hermes bot',
adapter: 'hermes',
providerType: 'openrouter',
apiKey: 'sk-or-v1-test-key',
}),
).rejects.toThrow(/modelId/i)
expect(agents).toHaveLength(0)
modelId: 'anthropic/claude-haiku-4.5',
})
const homeDir = join(
browserosDir,
'vm',
'hermes',
'harness',
agent.id,
'home',
)
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
const env = readFileSync(join(homeDir, '.env'), 'utf8')
expect(yaml).toContain('"openrouter"')
expect(yaml).toContain('"anthropic/claude-haiku-4.5"')
expect(env).toContain('OPENROUTER_API_KEY=sk-or-v1-test-key')
})
})
it('rejects Hermes agent creation when apiKey is missing', async () => {
await withHermesBrowserosDir(async ({ agents, service }) => {
await expect(
service.createAgent({
name: 'Hermes bot',
adapter: 'hermes',
providerType: 'openrouter',
modelId: 'anthropic/claude-haiku-4.5',
}),
).rejects.toThrow(/apiKey/i)
expect(agents).toHaveLength(0)
})
})
it('rejects Hermes agent creation when providerType is missing', async () => {
await withHermesBrowserosDir(async ({ agents, service }) => {
await expect(
service.createAgent({ name: 'Hermes bot', adapter: 'hermes' }),
).rejects.toThrow(/providerType/i)
expect(agents).toHaveLength(0)
})
})
it('rejects Hermes agent creation when modelId is missing', async () => {
await withHermesBrowserosDir(async ({ agents, service }) => {
await expect(
service.createAgent({
name: 'Hermes bot',
adapter: 'hermes',
providerType: 'openrouter',
apiKey: 'sk-or-v1-test-key',
}),
).rejects.toThrow(/modelId/i)
expect(agents).toHaveLength(0)
})
})
it('writes provider:custom + base_url for openai-compatible providers', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
const agent = await service.createAgent({
name: 'Custom Hermes',
adapter: 'hermes',
providerType: 'openai-compatible',
apiKey: 'sk-test',
modelId: 'my-model',
baseUrl: 'https://api.example.com/v1',
})
const homeDir = join(
browserosDir,
'vm',
'hermes',
'harness',
agent.id,
'home',
)
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
const env = readFileSync(join(homeDir, '.env'), 'utf8')
// Hermes has no provider key called "openai" — the canonical shape
// for any OpenAI-compatible endpoint is `provider: custom` with
// `base_url` set. Hermes then short-circuits provider lookup and
// calls the URL directly using OPENAI_API_KEY.
expect(yaml).toContain('"custom"')
expect(yaml).toContain('"my-model"')
expect(yaml).toContain('"https://api.example.com/v1"')
expect(env).toContain('OPENAI_API_KEY=sk-test')
})
it('falls back to OpenAI default base_url for the openai provider type', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
const agent = await service.createAgent({
name: 'OpenAI Hermes',
adapter: 'hermes',
providerType: 'openai',
apiKey: 'sk-openai-test',
modelId: 'gpt-4o-mini',
// No baseUrl supplied — provider:custom still requires one,
// so the mapping's defaultBaseUrl must take over.
})
const homeDir = join(
browserosDir,
'vm',
'hermes',
'harness',
agent.id,
'home',
)
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
expect(yaml).toContain('"custom"')
expect(yaml).toContain('"gpt-4o-mini"')
expect(yaml).toContain('"https://api.openai.com/v1"')
})
it('rejects openai-compatible Hermes agent creation when baseUrl is missing', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
await expect(
service.createAgent({
await withHermesBrowserosDir(async ({ browserosDir, service }) => {
const agent = await service.createAgent({
name: 'Custom Hermes',
adapter: 'hermes',
providerType: 'openai-compatible',
apiKey: 'sk-test',
modelId: 'my-model',
}),
).rejects.toThrow(/baseUrl/i)
expect(agents).toHaveLength(0)
baseUrl: 'https://api.example.com/v1',
})
const homeDir = join(
browserosDir,
'vm',
'hermes',
'harness',
agent.id,
'home',
)
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
const env = readFileSync(join(homeDir, '.env'), 'utf8')
// Hermes has no provider key called "openai" — the canonical shape
// for any OpenAI-compatible endpoint is `provider: custom` with
// `base_url` set. Hermes then short-circuits provider lookup and
// calls the URL directly using OPENAI_API_KEY.
expect(yaml).toContain('"custom"')
expect(yaml).toContain('"my-model"')
expect(yaml).toContain('"https://api.example.com/v1"')
expect(env).toContain('OPENAI_API_KEY=sk-test')
})
})
it('falls back to OpenAI default base_url for the openai provider type', async () => {
await withHermesBrowserosDir(async ({ browserosDir, service }) => {
const agent = await service.createAgent({
name: 'OpenAI Hermes',
adapter: 'hermes',
providerType: 'openai',
apiKey: 'sk-openai-test',
modelId: 'gpt-4o-mini',
// No baseUrl supplied — provider:custom still requires one,
// so the mapping's defaultBaseUrl must take over.
})
const homeDir = join(
browserosDir,
'vm',
'hermes',
'harness',
agent.id,
'home',
)
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
expect(yaml).toContain('"custom"')
expect(yaml).toContain('"gpt-4o-mini"')
expect(yaml).toContain('"https://api.openai.com/v1"')
})
})
it('rejects openai-compatible Hermes agent creation when baseUrl is missing', async () => {
await withHermesBrowserosDir(async ({ agents, service }) => {
await expect(
service.createAgent({
name: 'Custom Hermes',
adapter: 'hermes',
providerType: 'openai-compatible',
apiKey: 'sk-test',
modelId: 'my-model',
}),
).rejects.toThrow(/baseUrl/i)
expect(agents).toHaveLength(0)
})
})
it('rejects Hermes agent creation when providerType is not in the supported set', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
await withHermesBrowserosDir(async ({ agents, service }) => {
await expect(
service.createAgent({
name: 'Unknown Hermes',
adapter: 'hermes',
providerType: 'bedrock',
apiKey: 'sk-test',
modelId: 'm',
}),
).rejects.toThrow(/not supported/i)
expect(agents).toHaveLength(0)
})
})
})
async function withHermesBrowserosDir<T>(
run: (input: {
agents: AgentDefinition[]
browserosDir: string
service: AgentHarnessService
}) => Promise<T>,
): Promise<T> {
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const previousBrowserosDir = process.env.BROWSEROS_DIR
process.env.BROWSEROS_DIR = browserosDir
const agents: AgentDefinition[] = []
try {
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)
})
})
return await run({ agents, browserosDir, service })
} finally {
if (previousBrowserosDir === undefined) {
delete process.env.BROWSEROS_DIR
} else {
process.env.BROWSEROS_DIR = previousBrowserosDir
}
await rm(browserosDir, { recursive: true, force: true })
}
}
function stubRuntime(): AgentRuntime {
return {

View File

@@ -984,7 +984,6 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
lockDir: stateDir,
}
const hermesRuntime = new HermesContainerRuntime(fakeManagedDeps, {
browserosDir,
hermesHarnessHostDir: join(browserosDir, 'vm', 'hermes', 'harness'),
})
getAgentRuntimeRegistry().register(hermesRuntime)

View File

@@ -0,0 +1,71 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { describe, expect, it } from 'bun:test'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
getHermesAgentHomeHostDir,
getHermesHarnessHostDir,
writeHermesPerAgentProvider,
} from '../../../../src/lib/agents/hermes/hermes-paths'
import { getHermesProviderMapping } from '../../../../src/lib/agents/hermes/hermes-provider-map'
describe('Hermes adapter helpers', () => {
it('resolves Hermes state under the BrowserOS VM state root', () => {
const browserosDir = '/tmp/browseros-test'
expect(getHermesHarnessHostDir(browserosDir)).toBe(
'/tmp/browseros-test/vm/hermes/harness',
)
expect(
getHermesAgentHomeHostDir({ browserosDir, agentId: 'agent-1' }),
).toBe('/tmp/browseros-test/vm/hermes/harness/agent-1/home')
})
it('writes per-agent provider config from the Hermes provider map', async () => {
const browserosDir = await mkdtemp(join(tmpdir(), 'browseros-hermes-'))
try {
const mapping = getHermesProviderMapping('openai')
expect(mapping).toEqual({
hermesProvider: 'custom',
envVarName: 'OPENAI_API_KEY',
requiresBaseUrl: false,
defaultBaseUrl: 'https://api.openai.com/v1',
})
await writeHermesPerAgentProvider({
browserosDir,
agentId: 'agent-1',
providerId: mapping?.hermesProvider as string,
envVarName: mapping?.envVarName as string,
apiKey: 'sk-test',
modelId: 'gpt-5.5',
baseUrl: mapping?.defaultBaseUrl,
})
const home = getHermesAgentHomeHostDir({
browserosDir,
agentId: 'agent-1',
})
await expect(readFile(join(home, 'config.yaml'), 'utf8')).resolves.toBe(
[
'model:',
' default: "gpt-5.5"',
' provider: "custom"',
' base_url: "https://api.openai.com/v1"',
'',
].join('\n'),
)
await expect(readFile(join(home, '.env'), 'utf8')).resolves.toBe(
['OPENAI_API_KEY=sk-test', ''].join('\n'),
)
} finally {
await rm(browserosDir, { recursive: true, force: true })
}
})
})

View File

@@ -0,0 +1,49 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { describe, expect, it } from 'bun:test'
import { OPENCLAW_GATEWAY_CONTAINER_PORT } from '@browseros/shared/constants/openclaw'
import {
type OpenclawGatewayAccessor,
resolveOpenclawAcpCommand,
} from '../../../../src/lib/agents/openclaw/acp-command'
describe('resolveOpenclawAcpCommand', () => {
const gateway: OpenclawGatewayAccessor = {
getContainerName: () => 'browseros-openclaw-openclaw-gateway-1',
getLimaHomeDir: () => '/Users/dev/.browseros-dev/lima',
getLimactlPath: () => '/Applications/BrowserOS.app/limactl',
getVmName: () => 'browseros-vm',
}
it('builds the in-gateway ACP bridge command', () => {
const command = resolveOpenclawAcpCommand(gateway, 'agent:oc-123:main')
expect(command).toContain('env LIMA_HOME=/Users/dev/.browseros-dev/lima')
expect(command).toContain(
'/Applications/BrowserOS.app/limactl shell --workdir / browseros-vm --',
)
expect(command).toContain('nerdctl exec -i')
expect(command).toContain('-e OPENCLAW_HIDE_BANNER=1')
expect(command).toContain('-e OPENCLAW_SUPPRESS_NOTES=1')
expect(command).toContain('browseros-openclaw-openclaw-gateway-1')
expect(command).toContain(
`openclaw acp --url ws://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}`,
)
expect(command).toContain('--session agent:oc-123:main')
})
it('maps legacy non-agent session keys onto the main gateway agent', () => {
const command = resolveOpenclawAcpCommand(
gateway,
'openai-user:browseros:abc/def',
)
expect(command).toContain(
'--session agent:main:openai-user-browseros-abc-def',
)
})
})

View File

@@ -19,7 +19,9 @@ import {
getHermesRuntime,
HermesContainerRuntime,
resetAgentRuntimeRegistry,
startHermesRuntimeBestEffort,
} from '../../../../src/lib/agents/runtime'
import type { RuntimeAction } from '../../../../src/lib/agents/runtime/types'
import type {
ManagedContainerDeps,
MountRoot,
@@ -105,7 +107,6 @@ describe('HermesContainerRuntime', () => {
const browserosDir = extraConfig?.browserosDir ?? '/host/browseros'
const { deps, getCapturedSpec } = makeDeps({ lockDir })
const runtime = new HermesContainerRuntime(deps, {
browserosDir,
hermesHarnessHostDir: `${browserosDir}/vm/hermes/harness`,
})
return { runtime, getCapturedSpec, browserosDir }
@@ -145,7 +146,6 @@ describe('HermesContainerRuntime', () => {
},
})
const runtime = new HermesContainerRuntime(deps, {
browserosDir: '/host/browseros',
hermesHarnessHostDir: '/host/browseros/vm/hermes/harness',
})
await runtime.start()
@@ -157,7 +157,6 @@ describe('HermesContainerRuntime', () => {
const lockDir = mkTempDir()
const { deps } = makeDeps({ lockDir, exec: async () => 1 })
const runtime = new HermesContainerRuntime(deps, {
browserosDir: '/host/browseros',
hermesHarnessHostDir: '/host/browseros/vm/hermes/harness',
})
await expect(runtime.start()).rejects.toThrow(/probe failed/i)
@@ -252,4 +251,77 @@ describe('HermesContainerRuntime', () => {
)
})
})
describe('startHermesRuntimeBestEffort', () => {
it('configures Hermes and schedules install + start actions', async () => {
const actions: RuntimeAction[] = []
const runtime = {
executeAction: async (action: RuntimeAction) => {
actions.push(action)
},
} as HermesContainerRuntime
const result = startHermesRuntimeBestEffort({
resourcesDir: '/Applications/BrowserOS.app/Contents/Resources',
configureRuntime: (options) => {
expect(options).toEqual({
resourcesDir: '/Applications/BrowserOS.app/Contents/Resources',
})
return runtime
},
onError: (phase, error) => {
throw new Error(`${phase}: ${String(error)}`)
},
})
expect(result).toBe(runtime)
expect(actions).toEqual([{ type: 'install' }, { type: 'start' }])
})
it('returns null when Hermes configuration throws', () => {
const errors: Array<{ phase: string; message: string }> = []
const result = startHermesRuntimeBestEffort({
configureRuntime: () => {
throw new Error('unsupported')
},
onError: (phase, error) => {
errors.push({
phase,
message: error instanceof Error ? error.message : String(error),
})
},
})
expect(result).toBeNull()
expect(errors).toEqual([{ phase: 'configure', message: 'unsupported' }])
})
it('reports install and start failures without throwing', async () => {
const errors: Array<{ phase: string; message: string }> = []
const runtime = {
executeAction: async (action: RuntimeAction) => {
throw new Error(`${action.type} failed`)
},
} as HermesContainerRuntime
const result = startHermesRuntimeBestEffort({
configureRuntime: () => runtime,
onError: (phase, error) => {
errors.push({
phase,
message: error instanceof Error ? error.message : String(error),
})
},
})
expect(result).toBe(runtime)
await Promise.resolve()
await Promise.resolve()
expect(errors).toEqual([
{ phase: 'install', message: 'install failed' },
{ phase: 'start', message: 'start failed' },
])
})
})
})