diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agent-harness-types.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agent-harness-types.ts index 65159d4cc..10ae26485 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/agent-harness-types.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/agent-harness-types.ts @@ -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 } diff --git a/packages/browseros-agent/apps/server/src/api/services/agents/agent-harness-service.ts b/packages/browseros-agent/apps/server/src/api/services/agents/agent-harness-service.ts index 58f514b63..714ea49cc 100644 --- a/packages/browseros-agent/apps/server/src/api/services/agents/agent-harness-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/agents/agent-harness-service.ts @@ -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() - /** - * 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, diff --git a/packages/browseros-agent/apps/server/src/lib/agents/acpx-runtime.ts b/packages/browseros-agent/apps/server/src/lib/agents/acpx-runtime.ts index d10567250..7374d9de6 100644 --- a/packages/browseros-agent/apps/server/src/lib/agents/acpx-runtime.ts +++ b/packages/browseros-agent/apps/server/src/lib/agents/acpx-runtime.ts @@ -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 -- 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 ` 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: session that does not resolve to any - // provisioned gateway agent. - // - // Harness keys are `agent::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=` 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, diff --git a/packages/browseros-agent/apps/server/src/api/services/hermes/hermes-paths.ts b/packages/browseros-agent/apps/server/src/lib/agents/hermes/hermes-paths.ts similarity index 98% rename from packages/browseros-agent/apps/server/src/api/services/hermes/hermes-paths.ts rename to packages/browseros-agent/apps/server/src/lib/agents/hermes/hermes-paths.ts index d2fd58185..49144771e 100644 --- a/packages/browseros-agent/apps/server/src/api/services/hermes/hermes-paths.ts +++ b/packages/browseros-agent/apps/server/src/lib/agents/hermes/hermes-paths.ts @@ -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: `/vm/hermes`. */ export function getHermesHostStateDir(browserosDir?: string): string { diff --git a/packages/browseros-agent/apps/server/src/api/services/hermes/hermes-provider-map.ts b/packages/browseros-agent/apps/server/src/lib/agents/hermes/hermes-provider-map.ts similarity index 100% rename from packages/browseros-agent/apps/server/src/api/services/hermes/hermes-provider-map.ts rename to packages/browseros-agent/apps/server/src/lib/agents/hermes/hermes-provider-map.ts diff --git a/packages/browseros-agent/apps/server/src/lib/agents/openclaw/acp-command.ts b/packages/browseros-agent/apps/server/src/lib/agents/openclaw/acp-command.ts new file mode 100644 index 000000000..800e85da3 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/agents/openclaw/acp-command.ts @@ -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 -- 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 ` 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: session that does not resolve to any + // provisioned gateway agent. + // + // Harness keys are `agent::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=` 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(' ') +} diff --git a/packages/browseros-agent/apps/server/src/lib/agents/runtime/hermes-container-runtime.ts b/packages/browseros-agent/apps/server/src/lib/agents/runtime/hermes-container-runtime.ts index 5f72dea66..d7cafddf8 100644 --- a/packages/browseros-agent/apps/server/src/lib/agents/runtime/hermes-container-runtime.ts +++ b/packages/browseros-agent/apps/server/src/lib/agents/runtime/hermes-container-runtime.ts @@ -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), + }) +} diff --git a/packages/browseros-agent/apps/server/src/lib/agents/runtime/index.ts b/packages/browseros-agent/apps/server/src/lib/agents/runtime/index.ts index 3392ee4b0..f12d3d748 100644 --- a/packages/browseros-agent/apps/server/src/lib/agents/runtime/index.ts +++ b/packages/browseros-agent/apps/server/src/lib/agents/runtime/index.ts @@ -30,6 +30,8 @@ export { HermesContainerRuntime, type HermesContainerRuntimeConfig, prepareHermesContext, + type StartHermesRuntimeBestEffortOptions, + startHermesRuntimeBestEffort, } from './hermes-container-runtime' export { HostProcessAgentRuntime, diff --git a/packages/browseros-agent/apps/server/src/main.ts b/packages/browseros-agent/apps/server/src/main.ts index c25632141..2ec8e5a65 100644 --- a/packages/browseros-agent/apps/server/src/main.ts +++ b/packages/browseros-agent/apps/server/src/main.ts @@ -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 }) } diff --git a/packages/browseros-agent/apps/server/tests/api/services/agents/agent-harness-service.test.ts b/packages/browseros-agent/apps/server/tests/api/services/agents/agent-harness-service.test.ts index 83de14abc..6ebcd296d 100644 --- a/packages/browseros-agent/apps/server/tests/api/services/agents/agent-harness-service.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/services/agents/agent-harness-service.test.ts @@ -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( + run: (input: { + agents: AgentDefinition[] + browserosDir: string + service: AgentHarnessService + }) => Promise, +): Promise { + 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 { diff --git a/packages/browseros-agent/apps/server/tests/lib/agents/acpx-runtime.test.ts b/packages/browseros-agent/apps/server/tests/lib/agents/acpx-runtime.test.ts index be67d1449..00a3e6482 100644 --- a/packages/browseros-agent/apps/server/tests/lib/agents/acpx-runtime.test.ts +++ b/packages/browseros-agent/apps/server/tests/lib/agents/acpx-runtime.test.ts @@ -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) diff --git a/packages/browseros-agent/apps/server/tests/lib/agents/hermes/hermes-paths-provider-map.test.ts b/packages/browseros-agent/apps/server/tests/lib/agents/hermes/hermes-paths-provider-map.test.ts new file mode 100644 index 000000000..0b7fbc12c --- /dev/null +++ b/packages/browseros-agent/apps/server/tests/lib/agents/hermes/hermes-paths-provider-map.test.ts @@ -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 }) + } + }) +}) diff --git a/packages/browseros-agent/apps/server/tests/lib/agents/openclaw/acp-command.test.ts b/packages/browseros-agent/apps/server/tests/lib/agents/openclaw/acp-command.test.ts new file mode 100644 index 000000000..28548bc5e --- /dev/null +++ b/packages/browseros-agent/apps/server/tests/lib/agents/openclaw/acp-command.test.ts @@ -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', + ) + }) +}) diff --git a/packages/browseros-agent/apps/server/tests/lib/agents/runtime/hermes-container-runtime.test.ts b/packages/browseros-agent/apps/server/tests/lib/agents/runtime/hermes-container-runtime.test.ts index 09fc72f49..1a39d00c7 100644 --- a/packages/browseros-agent/apps/server/tests/lib/agents/runtime/hermes-container-runtime.test.ts +++ b/packages/browseros-agent/apps/server/tests/lib/agents/runtime/hermes-container-runtime.test.ts @@ -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' }, + ]) + }) + }) })