mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 15:46:22 +00:00
refactor(agent): clean up hermes adapter structure (#994)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
@@ -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(' ')
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ export {
|
||||
HermesContainerRuntime,
|
||||
type HermesContainerRuntimeConfig,
|
||||
prepareHermesContext,
|
||||
type StartHermesRuntimeBestEffortOptions,
|
||||
startHermesRuntimeBestEffort,
|
||||
} from './hermes-container-runtime'
|
||||
export {
|
||||
HostProcessAgentRuntime,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user