Compare commits

...

7 Commits

Author SHA1 Message Date
Nikhil Sonti
cba38ab81a fix(agent): DRY up sidebar offset — hoist pl-14 to parent div
Move pl-14 from the two <main> branches to their shared parent div
so any future layout branch gets the rail offset automatically.
Functionally equivalent; verified NewTabChat uses absolute inset-0
relative to its own <main>, so the chat layout is unaffected.
2026-05-07 09:51:35 -07:00
Nikhil Sonti
abfc32b0c1 fix(agent): offset main content by collapsed sidebar width to prevent overlap
Add pl-14 (56px = w-14) to both main branches in SidebarLayout so the
content is always offset to the right of the fixed overlay sidebar.
Previously, on viewports narrower than ~1300px the expanded sidebar
would visually overlap the left edge of the centered content.
2026-05-07 09:38:31 -07:00
shivammittal274
7a2a8e09bc feat(agent): add Hermes as 4th ACPX adapter (in-VM container, BrowserOS-managed providers) (#956)
* feat(agent): add Hermes as a 4th ACPX adapter (Phase A)

Adds Hermes Agent (NousResearch/hermes-agent) as a host-process ACPX
adapter, mirroring the Claude Code pattern.

- agent-types.ts: extend AgentAdapter union with 'hermes'
- agent-catalog.ts: add Hermes catalog entry
- lib/agents/hermes/prepare.ts (new): minimal prepare using prepareBrowserosManagedContext
- acpx-agent-adapter.ts: register the adapter
- acpx-runtime.ts: add 'hermes' branch returning 'hermes acp' (host)
- AdapterIcon.tsx: add Hermes icon
- db schema + supporting frontend types/literals updated for the new adapter

Phase A scope: host-process only. Phase A.5 swaps to nerdctl exec
into a Hermes container.

OpenClaw is untouched. Verified by all 6 POC spikes
(plans/features/claude-browseros-hermes-poc/findings.md).

* fix(agent): address Hermes adapter review issues

- NewAgentDialog: add 'hermes' to onValueChange guard so the dropdown
  option actually wires through onRuntimeChange/onHarnessAdapterChange
  (was a no-op before — selecting Hermes silently kept previous value)
- tests/acpx-runtime: add coverage for the new 'hermes' registry branch
- tests/acpx-agent-adapter: fold hermes prepare test into existing file,
  matching the pattern used for claude/codex/openclaw
- Delete tests/lib/agents/hermes-prepare.test.ts (now redundant)
- Reconcile install-mechanism comment between acpx-runtime.ts and
  agent-catalog.ts

* fix(agent): make Hermes adapter actually work end-to-end

Two surgical fixes uncovered while running the Phase A smoke test
through the BrowserOS chat HTTP API:

1. lib/agents/hermes/prepare.ts — seed per-agent HERMES_HOME from
   the user's global ~/.hermes/ on first use. ensureAgentHome only
   writes SOUL.md and MEMORY.md; without seeding config.yaml, .env,
   and auth.json, hermes acp comes up unconfigured and either hangs
   or errors with "No LLM provider configured." Copy is idempotent
   (skip if dest exists) so subsequent prepare calls don't clobber
   per-agent edits.

2. lib/agents/acpx-runtime.ts — wrap the hermes spawn in
   `bash -c "exec hermes acp | tee /dev/null"` to bridge Bun's
   socketpair-based child stdio with Python's asyncio.connect_write_pipe
   (which only drains correctly to a real pipe(2)). Without it, hermes'
   stdout never reaches the harness — verified by inspecting hermes
   process FDs: Bun gives the child unix sockets, asyncio queues writes
   that never become readable on Bun's end. With tee in the middle,
   hermes writes to a real pipe and tee bridges the bytes through the
   socket. Verified 2026-05-06 against hermes-agent 0.12.0 on macOS
   arm64 + Bun 1.3.6.

Smoke-test result with both fixes:
- ACP session created end-to-end
- BrowserOS MCP wired (96 browser tools registered with hermes)
- Reasoning + text streamed back through /agents/:id/sidepanel/chat
- Final stream: text-delta "PONG", finishReason "stop"

Updates the existing acpx-runtime test to assert the new spawn shape
(bash -c, tee /dev/null bridge) so the workaround can't silently regress.

* feat(agent): run Hermes adapter in Lima container (Phase A.5)

Move Hermes ACPX adapter from host-process spawn to running inside
docker.io/nousresearch/hermes-agent:v2026.4.30 in the existing
BrowserOS Lima VM, mirroring the OpenClaw container pattern.

Container lifecycle (api/services/hermes/hermes-container.ts):
- prewarm: ensure VM ready, pull image (or skip if already in
  containerd), start an idle container with /bin/sh -c "exec sleep
  infinity" so the harness can nerdctl exec into it per turn
- Tini bypassed — tini 0.19.0 in upstream image getopt-parses any
  -x token even after PROGRAM, breaking /bin/sh -c
- --add-host host.containers.internal:<vm-gateway> so hermes inside
  the container can reach the BrowserOS HTTP MCP endpoint
- Bind-mount <browserosDir>/vm/hermes/harness onto /data/agents/harness
  so per-agent HERMES_HOME dirs are visible to the container

Spawn (acpx-runtime.ts):
- HermesGatewayAccessor interface (mirrors OpenclawGatewayAccessor)
- resolveHermesAcpCommand builds:
  env LIMA_HOME=... limactl shell --workdir / browseros-vm --
    nerdctl exec -i -e PYTHONUNBUFFERED=1 -e HERMES_HOME=... <container>
    /opt/hermes/.venv/bin/hermes acp
- Absolute path /opt/hermes/.venv/bin/hermes (not bare "hermes") since
  upstream image's PATH is set by its entrypoint script which we
  override to keep the container idle
- Falls back to host-process spawn when no HermesGatewayAccessor wired
  (test path / dev fallback)
- Drops the host-mode bash+tee workaround — limactl/SSH/nerdctl pipe
  chain is sufficient for asyncio's pipe writer

MCP wiring:
- New PreparedAcpxAgentContext.browserosMcpHost field threads through
  prepare → getRuntime → createBrowserosMcpServers
- Hermes prepare sets browserosMcpHost='host.containers.internal' so
  the URL injected into newSession.mcpServers resolves from inside
  the container; other adapters keep '127.0.0.1' default

Per-agent home (lib/agents/hermes/prepare.ts):
- HERMES_HOME points at /data/agents/harness/<agentId>/home (in-container)
- Host-side seedHermesHomeFromGlobal still copies ~/.hermes/{config.yaml,
  .env, auth.json} into the per-agent home; the volume mount makes them
  visible inside the container
- New api/services/hermes/hermes-paths.ts holds host/container path helpers

End-to-end smoke tests against the dev server (clean Lima state):
- Plain text: PONG round-trip via /sidepanel/chat ✓
- Multi-turn context: RUBY-7421 stored + recalled ✓
- Multi-agent isolation: agent 2 doesn't see agent 1's secret ✓
- MCP tool execution: mcp_browseros_browseros_info fires ✓
- Image attachment via /chat: model identifies "Red" from a 128x128 PNG ✓
- Concurrent turns + 409 attachUrl: full attach streams the in-flight
  Pacific Ocean essay turn cleanly ✓
- Cancel midstream + recovery turn: ALIVE response ✓
- Persistence across server restart: agents survive ✓

Companion knowledge doc:
plans/features/claude-browseros-hermes-acp-knowledge.md

* feat(agent): per-agent provider/key for Hermes adapter

Lets users create multiple Hermes agents each with its own provider,
model, and API key. NewAgentDialog now shows provider/model/key fields
inline when 'Hermes' is selected. On submit, the harness writes the
per-agent <browserosDir>/vm/hermes/harness/<agentId>/home/{config.yaml,
.env} directly so the agent has the right config from turn 1 — no
dependency on the user having run `hermes setup` outside BrowserOS.

The existing seedHermesHomeFromGlobal flow remains as a fallback for
agents created without provider fields (e.g. via direct API or with
an existing ~/.hermes/ install).

Backend:
- shared/constants/hermes.ts: HERMES_SUPPORTED_PROVIDERS registry
  (openrouter, anthropic, openai, custom — bedrock follow-up)
- api/services/hermes/hermes-paths.ts: writeHermesPerAgentProvider
- agent-harness-service: writes per-agent config.yaml + .env in
  createAgent when adapter=hermes and apiKey present
- routes/agents.ts: relax modelId catalog validation for adapter=hermes
  (catalog has empty models[] by design; per-agent modelId is free-form)
- tests/agent-harness-service: cover write + skip paths

Frontend:
- HermesProviderFields.tsx (new): provider dropdown, model field, API
  key + optional baseUrl when provider=custom
- NewAgentDialog: render the new fields when adapter=hermes
- agents-page-actions: thread fields through createHarnessAgent
- AgentsPage / agent-harness-types: minor pass-through edits

Smoke-tested end-to-end against the dev server (clean Hermes per-agent
home, no ~/.hermes/ seed): create agent with apiKey + modelId, files
written at the per-agent path with mode 0600, first chat returns the
expected response, all without touching ~/.hermes/.

* feat(agent): source Hermes provider config from BrowserOS LLM providers

Replace the Hermes-specific provider/model/API-key form in New Agent
with a chooser that pulls from the same global LLM providers OpenClaw
uses (Settings → BrowserOS AI). Backend rejects creation with a 400
when the selected provider is missing required fields (apiKey, modelId,
plus baseUrl for openai-compatible) or is not in the Hermes-supported
set; the ~/.hermes/ fallback is removed so Hermes agents always carry
their own per-agent config.
2026-05-07 21:54:36 +05:30
shivammittal274
6f8da5b7fb refactor(openclaw): TKT-788 cleanup (relanded, openclaw-only) — bump image, lock no-auth, delete observer + image bypass (#954)
* refactor(openclaw): TKT-788 cleanup — bump image, lock no-auth, delete observer + image bypass

Re-lands the openclaw-only changes from #934 (reverted in #953 because the
original PR's working tree had stale rollback content for
`packages/browseros/tools/patch/`). This commit is the same openclaw
diff with zero changes outside `packages/browseros-agent/`.

What changes (TKT-788 work-streams A + B + C):

WS-A — bundled gateway no-auth:
- Bump image from `ghcr.io/openclaw/openclaw:2026.4.12` to
  `ghcr.io/browseros-ai/openclaw:2026.5.2-browseros.1` (BrowserOS-
  pinned variant with the no-auth contract baked in).
- Configure gateway with `auth.mode: 'none'`; remove the device-auth
  bootstrap dance that the older binary required.
- Delete the per-call token plumbing the http-client / observer / chat-
  client carried (340 LOC). The harness still passes a stable token in
  headers for backwards-compat with code that hasn't been re-pointed yet,
  but it is no longer required by the gateway.

WS-C — delete the image-attachment bypass:
- The HTTP `/v1/chat/completions` carve-out for OpenClaw image turns
  is gone. Image attachments now ride through ACP as image content
  blocks (which acpx 0.6.x supports natively for openclaw, claude, codex).
- Delete `openclaw-gateway-chat-client.ts` (211 LOC) and `image-turn.ts`
  (219 LOC).
- Drop `maybeHandleTurn` from the `AcpxAgentAdapter` interface and
  the openclaw entry. `AcpxAdapterTurnInput` removed.
- Drop the corresponding 'diverts OpenClaw image turns to the gateway
  chat client' test from `acpx-runtime.test.ts`.

WS-B — replace the WS observer with harness events:
- Delete `openclaw-observer.ts` (276 LOC) — no more parallel WS
  subscription, no more `new OpenClawObserver`, no more
  `ensureObserverConnected` / `observer.disconnect()` plumbing.
- Wire `AgentHarnessService` to receive turn-lifecycle events from
  the runtime stream itself (`turnLifecycleListeners`) and feed
  ClawSession from those, preserving the dashboard SSE shape.

Net: 314 insertions / 1144 deletions, all under
`packages/browseros-agent/`. Typecheck clean across all 6 packages.
946 server tests pass (1 unrelated CDP-dependent test skipped — same
state as origin/dev).

Reference: TKT-788. The patch-CLI rollback that was in the squash of
#934 is intentionally NOT in this commit.

* fix(openclaw): handle 2026.5.4 acp-cli envelope shapes (media + injected timestamp) + bump image

OpenClaw 2026.5.4 (the BrowserOS-pinned image variant with the no-auth
handshake bypass needed for cron tool calls from inside ACP) introduced
two new envelope prefix shapes that the post-bypass-deletion path now
surfaces in user-message text:

  [media attached: <internal-path> (<mime>)]
  [<weekday> <YYYY-MM-DD HH:MM> <TZ>] [Working directory: <path>]
  <BrowserOS role envelope>

The previous cleaner only matched a leading [Working directory: ...]
\n\n line. With media + timestamp prefixes ahead of it the anchor
no longer matched, so image-attachment user turns rendered with
8+ lines of envelope leak in the chat panel.

Replaces the single OPENCLAW_WORKDIR_PREFIX with three content-shape-
anchored patterns chained through stripOpenClawAcpCliEnvelope():

  1. [media attached: <path> (<mime>)]      ← repeats per attachment
  2. [<weekday> <YYYY-MM-DD HH:MM> <TZ>]    ← injectTimestamp
  3. [Working directory: <path>]            ← acp-cli prefixCwd

Each is anchored on its content shape (media attached:, weekday
abbrev + ISO date, Working directory:) rather than just '[…]', so
user-typed lines that happen to start with brackets are not eaten.

Also bumps OPENCLAW_IMAGE from 2026.5.2-browseros.1 to
2026.5.4-browseros.1. The 5.2 image refused tool-side WS connections
with 'device identity required' even though gateway auth.mode=none —
PR #6 in browseros-ai/openclaw added the OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH
bypass that ships in 5.4. Without 5.4, the cron tool (and any other
tool that opens a fresh gateway WS from inside the embedded runner)
fails with 1008.

Verified end-to-end with the BrowserOS chat endpoint:
- Plain text turn: clean
- Image attachment turn: clean (was leaking 8 envelope lines pre-fix)
- One-shot kind:at cron fires, PING fire renders clean
- Second openclaw agent creates, runs, history isolated

15/15 history-mapper unit tests pass; typecheck clean across all
packages.
2026-05-07 02:26:25 +05:30
shivammittal274
50cbe48558 Revert "refactor(openclaw): lock no-auth gateway, bump image, delete token pl…" (#953)
This reverts commit d81b99c8e3.
2026-05-07 01:49:50 +05:30
shivammittal274
d81b99c8e3 refactor(openclaw): lock no-auth gateway, bump image, delete token plumbing (TKT-788 WS-A) (#934)
* fix: disable bundled OpenClaw gateway auth

* refactor(openclaw): delete token plumbing now that auth is locked off

Builds on the cherry-picked spike (#933). With gateway.auth.mode=none
locked in as the only path the bundled gateway runs, the BrowserOS-side
token machinery becomes dead weight. This commit deletes:

- OpenClawService: token field, tokenLoaded, gatewayAuthMode state
  machine, getGatewayToken(), getGatewayHttpToken(),
  ensureTokenLoaded(), refreshGatewayAuthToken(),
  loadTokenFromConfig() and all six lifecycle call sites.
- OpenclawGatewayAccessor.getGatewayToken interface field.
- OpenClawHttpClient / OpenClawGatewayChatClient: optional getToken
  constructor arg and authHeaders() helpers.
- OpenClawObserver: gatewayToken field/parameter and the auth.token
  branch in the connect frame.
- GatewayContainerSpec.gatewayToken and the
  OPENCLAW_GATEWAY_TOKEN env wiring; the
  OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH=1 env is now always set
  rather than conditional.

Test suites: dropped bearer-token assertions and the two persisted-token
tests in openclaw-service that asserted deleted behavior.

Net: -310 LOC across src + tests, with 118 openclaw + acpx tests still
green. Typecheck and biome clean.

Reference: TKT-788 (move OpenClaw integration to ACPX runtime), WS-A.

* refactor(openclaw): delete gateway image bypass, route image turns via ACP (TKT-788 WS-C) (#935)

* refactor(openclaw): delete gateway image bypass, route image turns through ACP

The browseros-ai/openclaw ACP bridge accepts image content blocks
natively (extractAttachmentsFromPrompt at openclaw/src/acp/event-mapper.ts:92,
forwarded via chat.send attachments at translator.ts:295), so the
BrowserOS-side carve-out that diverted image-bearing turns to the
gateway HTTP /v1/chat/completions endpoint is no longer needed.

Deletes:

- apps/server/src/api/services/openclaw/openclaw-gateway-chat-client.ts
- The corresponding test file
- AcpxRuntime.sendOpenclawViaGateway, persistGatewayTurn,
  recordToOpenAIMessages helpers
- The image-attachment carve-out branch in AcpxRuntime.send
- openclawGatewayChat option from AcpxRuntime + AgentHarnessService
  + agent routes ctor wiring
- The randomUUID import (only the deleted helper used it)
- The acpx-runtime test for the deleted carve-out

Net: 614 LOC removed, 0 added, all 142 openclaw + acpx + agent tests
still green.

Reference: TKT-788, WS-C. Stacked on WS-A (#934).

* refactor(openclaw): delete WS observer, feed ClawSession from harness events (#936)

The openclaw-observer.ts WebSocket observer was a second tap on the
same gateway events the AcpxRuntime already sees as ACP session/update
notifications. Replace it with a pull from the AgentHarnessService's
turn lifecycle stream — keeping ClawSession and the /openclaw/dashboard
SSE endpoint shape unchanged for the BrowserOS UI.

Changes:

- AgentHarnessService: emit `turn_started` / `turn_event` / `turn_ended`
  to subscribers via a new `onTurnLifecycle(listener)` API. Wired around
  the existing `notifyTurnStarted/Ended` calls and inside the
  per-event read loop.
- agents route: forward an optional `onTurnLifecycle` dep into the
  service it constructs.
- server.ts: subscribe and route OpenClaw-adapter events to
  `OpenClawService.recordAgentTurnEvent(agentId, sessionKey, event)`.
- OpenClawService: new `recordAgentTurnEvent` method that maps stream
  events to ClawSession transitions (working/idle/error + currentTool
  from `tool_call` events). Keeps the existing
  `onAgentStatusChange` / `getAgentState` / `getDashboard` API.
- Delete `openclaw-observer.ts` (276 LOC) and all observer wiring
  (`new OpenClawObserver`, `ensureObserverConnected`, three
  `observer.disconnect()` call sites, the import).

Net: 276 LOC removed from the observer; ~130 LOC added across harness
event plumbing + recorder method. -146 LOC overall, all 141 tests still
green, typecheck clean, biome clean.

Reference: TKT-788, WS-B (Path 1: keep ClawSession + dashboard SSE shape).
Independent of WS-A (#934) and WS-C (#935); will rebase on top of
whichever lands first.

---------

Co-authored-by: Nikhil Sonti <nikhilsv92@gmail.com>
2026-05-07 01:40:37 +05:30
shivammittal274
86cb03a1fc fix(openclaw): drop BrowserOS-envelope regexes in history mapper (#952)
* fix(openclaw): drop BrowserOS-envelope regexes in history mapper

Replace the four BrowserOS-side regex strips (`<role>`,
`<user_request>`, `<system-reminder>`, `[Working directory:]`)
in history-mapper with a single call to
`unwrapBrowserosAcpUserMessage`. That helper is the same exact-string
matcher acpx-runtime already uses for non-OpenClaw history paths
(chat history endpoint, listing's `lastUserMessage`); it anchors on
the exact constants `buildBrowserosAcpPrompt` writes, so matcher and
wrapper travel together.

Also drops two patterns that were defensive-only with no emit site in
the codebase (`[Working directory:]` prefix and trailing
`<system-reminder>` block), and updates the corresponding tests to
use the realistic envelope shape `buildBrowserosAcpPrompt` actually
produces.

The OpenClaw-injected scaffolding patterns (cron prefix, queued-
marker, subagent context) stay in place for now — replacing those
needs either a side-channel cache keyed on cron job id or a structured
`trigger` field on the gateway's history schema, tracked as a
follow-up.

* fix(openclaw): strip acp-cli's [Working directory:] prefix before BrowserOS unwrap

The previous commit incorrectly removed the workdir-prefix strip on the
assumption it was speculative defensive code with no live emit site.
Actually emitted by OpenClaw's acp-cli (`/app/dist/acp-cli-*.js` line
1361, `prefixCwd ? \`[Working directory: ${displayCwd}]\\n\\n...` style),
so live history rendering regressed: every user message surfaced with
a `[Working directory: /Users/...]\\n\\n<role>...` envelope intact.

Restore the strip as an exact-shape line match (`^\\[Working directory:
[^\\]]*\\]\\n\\n`) anchored on the closing bracket + double-newline so
path content is consumed without a content-shape regex. Apply it
ahead of `unwrapBrowserosAcpUserMessage` so the BrowserOS unwrap's
`^<role>` anchor can match the now-leading envelope.

Also fix the test fixture: the BrowserOS unwrap performs exact-prefix
match against the full `BROWSEROS_ACP_AGENT_INSTRUCTIONS` constant —
truncated `<role>...` test bodies didn't match. Tests now use the
verbatim constant text via a shared `ROLE_BLOCK` helper.

Verified live: 8/8 history entries render with no envelope leaks.
2026-05-06 23:54:09 +05:30
51 changed files with 1894 additions and 1218 deletions

View File

@@ -14,7 +14,7 @@ import { cn } from '@/lib/utils'
interface ConversationHeaderProps {
agent: HarnessAgent | null
fallbackName: string
fallbackAdapter: 'claude' | 'codex' | 'openclaw' | 'unknown'
fallbackAdapter: 'claude' | 'codex' | 'openclaw' | 'hermes' | 'unknown'
adapterHealth: AgentAdapterHealth | null
backLabel: string
backTarget: 'home' | 'page'

View File

@@ -1,4 +1,4 @@
import { Bot, Cpu, Sparkles } from 'lucide-react'
import { Bot, Cpu, Sparkles, Wand2 } from 'lucide-react'
import type { FC } from 'react'
import type { HarnessAgentAdapter } from './agent-harness-types'
@@ -23,6 +23,9 @@ export const AdapterIcon: FC<AdapterIconProps> = ({ adapter, className }) => {
case 'openclaw':
// OpenClaw — bot/automation framing.
return <Bot className={className} aria-label="OpenClaw" />
case 'hermes':
// Hermes — messenger god framing, wand evokes the agentic conjuring.
return <Wand2 className={className} aria-label="Hermes" />
default:
return <Bot className={className} aria-label="Agent" />
}
@@ -36,6 +39,8 @@ export function adapterLabel(adapter: HarnessAgentAdapter | 'unknown'): string {
return 'Codex'
case 'openclaw':
return 'OpenClaw'
case 'hermes':
return 'Hermes'
default:
return 'Agent'
}

View File

@@ -117,6 +117,7 @@ function inferAdapterFromLabel(label: string): HarnessAgentAdapter | 'unknown' {
if (lower === 'claude code') return 'claude'
if (lower === 'codex') return 'codex'
if (lower === 'openclaw') return 'openclaw'
if (lower === 'hermes') return 'hermes'
return 'unknown'
}

View File

@@ -10,6 +10,7 @@ import { createAgentPageActions } from './agents-page-actions'
import {
useDefaultAgentName,
useHarnessAgentDefaults,
useHermesProviderSelection,
useOpenClawProviderSelection,
} from './agents-page-hooks'
import {
@@ -106,6 +107,7 @@ export const AgentsPage: FC = () => {
)
const [harnessModelId, setHarnessModelId] = useState('')
const [harnessReasoningEffort, setHarnessReasoningEffort] = useState('')
const [createHermesProviderId, setCreateHermesProviderId] = useState('')
const [showTerminal, setShowTerminal] = useState(false)
const [cliAuthModalOpen, setCliAuthModalOpen] = useState(false)
const [pageError, setPageError] = useState<string | null>(null)
@@ -133,6 +135,14 @@ export const AgentsPage: FC = () => {
cliAuthModalOpen,
setCliAuthModalOpen,
})
const { selectableHermesProviders } = useHermesProviderSelection({
providers,
defaultProviderId,
createOpen,
createRuntime,
createHermesProviderId,
setCreateHermesProviderId,
})
useDefaultAgentName(createOpen, setNewName)
useHarnessAgentDefaults({
adapters,
@@ -226,11 +236,13 @@ export const AgentsPage: FC = () => {
createAgentPageActions({
createProviderId,
createRuntime,
createHermesProviderId,
harnessModelId,
harnessReasoningEffort,
navigate,
newName,
selectableOpenClawProviders,
selectableHermesProviders,
setupProviderId,
createHarnessAgent: createHarnessAgent.mutateAsync,
createOpenClawAgent,
@@ -386,6 +398,8 @@ export const AgentsPage: FC = () => {
harnessAdapterId={harnessAdapterId}
harnessModelId={harnessModelId}
harnessReasoningEffort={harnessReasoningEffort}
hermesProviders={selectableHermesProviders}
hermesSelectedProviderId={createHermesProviderId}
name={newName}
open={createOpen}
providers={selectableOpenClawProviders}
@@ -401,12 +415,14 @@ export const AgentsPage: FC = () => {
if (!open) {
setCreateError(null)
createHarnessAgent.reset()
setCreateHermesProviderId('')
}
}}
onRuntimeChange={setCreateRuntime}
onHarnessAdapterChange={handleHarnessAdapterChange}
onHarnessModelChange={setHarnessModelId}
onHarnessReasoningChange={setHarnessReasoningEffort}
onHermesProviderChange={setCreateHermesProviderId}
onNameChange={setNewName}
onProviderChange={setCreateProviderId}
/>

View File

@@ -40,6 +40,8 @@ interface NewAgentDialogProps {
harnessAdapterId: HarnessAgentAdapter
harnessModelId: string
harnessReasoningEffort: string
hermesProviders: ProviderOption[]
hermesSelectedProviderId: string
name: string
open: boolean
providers: ProviderOption[]
@@ -55,6 +57,7 @@ interface NewAgentDialogProps {
onHarnessAdapterChange: (adapter: HarnessAgentAdapter) => void
onHarnessModelChange: (modelId: string) => void
onHarnessReasoningChange: (reasoningEffort: string) => void
onHermesProviderChange: (providerId: string) => void
onNameChange: (name: string) => void
onProviderChange: (providerId: string) => void
}
@@ -69,6 +72,8 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
harnessAdapterId,
harnessModelId,
harnessReasoningEffort,
hermesProviders,
hermesSelectedProviderId,
name,
open,
providers,
@@ -84,22 +89,29 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
onHarnessAdapterChange,
onHarnessModelChange,
onHarnessReasoningChange,
onHermesProviderChange,
onNameChange,
onProviderChange,
}) => {
const selectedHarnessAdapter =
adapters.find((adapter) => adapter.id === harnessAdapterId) ?? adapters[0]
const isHarnessRuntime = createRuntime !== 'openclaw'
const isHermesRuntime = createRuntime === 'hermes'
const isClassicHarnessRuntime = isHarnessRuntime && !isHermesRuntime
const openClawBlocked = createRuntime === 'openclaw' && !canManageOpenClaw
const cliBlocked =
createRuntime === 'openclaw' &&
!!selectedCliProvider &&
!cliAuthStatus?.loggedIn
const hermesBlocked =
isHermesRuntime &&
(hermesProviders.length === 0 || !hermesSelectedProviderId)
const canCreate =
Boolean(name.trim()) &&
!creating &&
!openClawBlocked &&
!cliBlocked &&
!hermesBlocked &&
(createRuntime === 'openclaw'
? providers.length > 0
: Boolean(selectedHarnessAdapter))
@@ -143,7 +155,8 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
if (
value === 'openclaw' ||
value === 'claude' ||
value === 'codex'
value === 'codex' ||
value === 'hermes'
) {
onRuntimeChange(value)
if (value !== 'openclaw') onHarnessAdapterChange(value)
@@ -196,7 +209,16 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
</>
) : null}
{isHarnessRuntime ? (
{isHermesRuntime ? (
<ProviderSelector
providers={hermesProviders}
defaultProviderId={defaultProviderId}
selectedId={hermesSelectedProviderId}
onSelect={onHermesProviderChange}
/>
) : null}
{isClassicHarnessRuntime ? (
<>
<div className="grid gap-2">
<Label htmlFor="harness-model">Model</Label>

View File

@@ -1,6 +1,6 @@
import type { AgentEntry } from './useOpenClaw'
export type HarnessAgentAdapter = 'claude' | 'codex' | 'openclaw'
export type HarnessAgentAdapter = 'claude' | 'codex' | 'openclaw' | 'hermes'
/**
* One file the harness attributed to the assistant turn that just
@@ -130,6 +130,17 @@ export interface CreateHarnessAgentInput {
adapter: HarnessAgentAdapter
modelId?: string
reasoningEffort?: string
/**
* Hermes-only — provider id from `HERMES_SUPPORTED_PROVIDERS`. When
* paired with `apiKey`, the backend writes a per-agent
* config.yaml + .env into the agent's HERMES_HOME so the first chat
* doesn't depend on the user having run `hermes setup` globally.
*/
providerType?: string
/** Hermes-only — API key paired with `providerType`. */
apiKey?: string
/** Hermes-only — base URL for the `custom` provider. */
baseUrl?: string
}
export interface HarnessHistoryReasoning {

View File

@@ -20,17 +20,22 @@ import type {
export interface AgentPageActionInput {
createProviderId: string
createRuntime: CreateAgentRuntime
createHermesProviderId: string
harnessModelId: string
harnessReasoningEffort: string
navigate: NavigateFunction
newName: string
selectableOpenClawProviders: ProviderOption[]
selectableHermesProviders: ProviderOption[]
setupProviderId: string
createHarnessAgent: (input: {
name: string
adapter: HarnessAgentAdapter
modelId?: string
reasoningEffort?: string
providerType?: string
apiKey?: string
baseUrl?: string
}) => Promise<HarnessAgent>
createOpenClawAgent: (
input: OpenClawAgentMutationInput,
@@ -114,20 +119,37 @@ export function createAgentPageActions(input: AgentPageActionInput) {
const handleHarnessCreate = async () => {
if (!input.newName.trim()) return
const isHermes = input.createRuntime === 'hermes'
// Hermes pulls every provider field from the user's selected entry
// in the global LLM-providers list (managed under AI Settings). The
// backend rejects creation if any required field is missing.
const hermesProvider = isHermes
? input.selectableHermesProviders.find(
(option) => option.id === input.createHermesProviderId,
)
: undefined
const effectiveModelId = isHermes
? hermesProvider?.modelId
: input.harnessModelId || undefined
input.setCreateError(null)
try {
const agent = await input.createHarnessAgent({
name: input.newName.trim(),
adapter: input.createRuntime as HarnessAgentAdapter,
modelId: input.harnessModelId || undefined,
modelId: effectiveModelId,
reasoningEffort: input.harnessReasoningEffort || undefined,
providerType: hermesProvider?.type,
apiKey: hermesProvider?.apiKey,
baseUrl: hermesProvider?.baseUrl,
})
input.setCreateOpen(false)
input.setNewName('')
track(AGENT_CREATED_EVENT, {
runtime: input.createRuntime,
model_id: input.harnessModelId || undefined,
model_id: effectiveModelId,
reasoning_effort: input.harnessReasoningEffort || undefined,
provider_type: hermesProvider?.type,
})
input.navigate(`/agents/${agent.id}`)
} catch (err) {
@@ -140,6 +162,7 @@ export function createAgentPageActions(input: AgentPageActionInput) {
openclaw: handleOpenClawCreate,
claude: handleHarnessCreate,
codex: handleHarnessCreate,
hermes: handleHarnessCreate,
}
void createByRuntime[input.createRuntime]()
}

View File

@@ -4,8 +4,9 @@ import type {
HarnessAdapterDescriptor,
HarnessAgentAdapter,
} from './agent-harness-types'
import type { CreateAgentRuntime } from './agents-page-types'
import type { CreateAgentRuntime, ProviderOption } from './agents-page-types'
import { toProviderOptions } from './agents-page-utils'
import { getHermesSupportedProviders } from './hermes-supported-providers'
import {
buildOpenClawCliProviderOptions,
findOpenClawCliProviderById,
@@ -171,3 +172,60 @@ export function useOpenClawProviderSelection(input: {
cliAuthError,
}
}
/**
* Mirror of useOpenClawProviderSelection but for Hermes. Hermes only
* needs the create-dialog flow (no setup dialog, no CLI providers), so
* this hook is much smaller — it just filters the global provider list
* to ones Hermes can drive and seeds the selected id when the dialog
* opens.
*/
export function useHermesProviderSelection(input: {
providers: LlmProviderConfig[]
defaultProviderId: string
createOpen: boolean
createRuntime: CreateAgentRuntime
createHermesProviderId: string
setCreateHermesProviderId: Dispatch<SetStateAction<string>>
}) {
const {
providers,
defaultProviderId,
createOpen,
createRuntime,
createHermesProviderId,
setCreateHermesProviderId,
} = input
const selectableHermesProviders = useMemo<ProviderOption[]>(
() =>
getHermesSupportedProviders(providers).map((provider) => ({
id: provider.id,
type: provider.type,
name: provider.name,
modelId: provider.modelId,
baseUrl: provider.baseUrl,
apiKey: provider.apiKey,
})),
[providers],
)
useEffect(() => {
if (selectableHermesProviders.length === 0) return
if (!createOpen || createRuntime !== 'hermes') return
if (createHermesProviderId) return
const fallbackId =
selectableHermesProviders.find((p) => p.id === defaultProviderId)?.id ??
selectableHermesProviders[0].id
setCreateHermesProviderId(fallbackId)
}, [
createHermesProviderId,
createOpen,
createRuntime,
defaultProviderId,
selectableHermesProviders,
setCreateHermesProviderId,
])
return { selectableHermesProviders }
}

View File

@@ -0,0 +1,30 @@
import {
HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES,
type HermesSupportedBrowserosProviderType,
} from '@browseros/shared/constants/hermes'
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
export function isHermesSupportedProviderType(
providerType: ProviderType,
): providerType is HermesSupportedBrowserosProviderType {
return (
HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES as readonly ProviderType[]
).includes(providerType)
}
/**
* Filters the user's global LLM providers down to ones Hermes can use.
* A provider qualifies when its type is in the Hermes-supported set
* AND it has an API key wired up. CLI-style providers (chatgpt-pro,
* github-copilot, qwen-code) and other unsupported types (browseros,
* ollama, lmstudio, bedrock, azure, google, moonshot) are filtered
* out — Hermes can't drive them today.
*/
export function getHermesSupportedProviders(
providers: LlmProviderConfig[],
): LlmProviderConfig[] {
return providers.filter(
(provider) =>
!!provider.apiKey && isHermesSupportedProviderType(provider.type),
)
}

View File

@@ -85,7 +85,8 @@ export const SidebarLayout: FC = () => {
return (
<RpcClientProvider>
<div className="relative min-h-screen bg-background">
{/* pl-14 offsets all content by the collapsed sidebar width (w-14 = 56px) so it never sits under the rail */}
<div className="relative min-h-screen bg-background pl-14">
{/* Sidebar - fixed overlay */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: hover interactions needed */}
<div
@@ -96,7 +97,6 @@ export const SidebarLayout: FC = () => {
<AppSidebar expanded={sidebarOpen} onOpenShortcuts={openShortcuts} />
</div>
{/* Main content - full width, centered */}
{location.pathname === '/home/chat' ? (
<main className="relative h-dvh overflow-hidden">
<Outlet />

View File

@@ -108,6 +108,7 @@ function formatAdapterName(adapter: HarnessAgentAdapter): string {
if (adapter === 'claude') return 'Claude Code'
if (adapter === 'codex') return 'Codex'
if (adapter === 'openclaw') return 'OpenClaw'
if (adapter === 'hermes') return 'Hermes'
return adapter
}

View File

@@ -503,7 +503,7 @@ async function scenarioConfig(): Promise<void> {
await tearDown(s)
return
}
const newValue = target.options![0].value
const newValue = target.options?.[0].value
console.log(`[config] setting configId=${target.id} value=${newValue}`)
try {
// @ts-expect-error - input shape varies

View File

@@ -14,7 +14,10 @@ import { stream } from 'hono/streaming'
import { formatUserMessage } from '../../agent/format-message'
import type { Browser } from '../../browser/browser'
import { createAcpUIMessageStreamResponse } from '../../lib/agents/acp-ui-message-stream'
import type { OpenclawGatewayAccessor } from '../../lib/agents/acpx-runtime'
import type {
HermesGatewayAccessor,
OpenclawGatewayAccessor,
} from '../../lib/agents/acpx-runtime'
import type {
ActiveTurnInfo,
TurnFrame,
@@ -35,6 +38,7 @@ import {
type AgentDefinitionWithActivity,
AgentHarnessService,
type GatewayStatusSnapshot,
HermesProviderConfigInvalidError,
InvalidAgentUpdateError,
MessageQueueFullError,
type OpenClawProvisioner,
@@ -46,7 +50,6 @@ import {
UnknownAgentError,
} from '../services/agents/agent-harness-service'
import type { FilePreview } from '../services/openclaw/file-preview'
import type { OpenClawGatewayChatClient } from '../services/openclaw/openclaw-gateway-chat-client'
import type { Env } from '../types'
import { resolveBrowserContextPageIds } from '../utils/resolve-browser-context-page-ids'
@@ -129,18 +132,26 @@ type AgentRouteDeps = {
openclawGateway?: OpenclawGatewayAccessor
/**
* Optional. Enables the image-attachment carve-out for OpenClaw
* agents — image-bearing turns route through the gateway HTTP
* `/v1/chat/completions` instead of the ACP bridge (which drops
* image content blocks).
*/
openclawGatewayChat?: OpenClawGatewayChatClient
/**
* Required to dual-create/delete `openclaw` adapter agents on the
* gateway side. Without this, openclaw create requests fail with 503.
*/
openclawProvisioner?: OpenClawProvisioner
/**
* Required when a `hermes` adapter agent is in use; harmless when
* absent (the AcpxRuntime falls back to a host-process spawn for
* tests / dev). Forwarded to the AcpxRuntime so it can spawn
* `hermes acp` inside the Hermes container.
*/
hermesGateway?: HermesGatewayAccessor
/** Optional override; defaults to a fresh in-memory checker. */
adapterHealth?: AdapterHealthChecker
/**
* Optional listener attached to the constructed harness. Receives
* turn lifecycle events for every running agent. Wired by the server
* to feed OpenClaw's ClawSession dashboard from the same stream the
* chat panel sees, so no second WS observer is needed.
*/
onTurnLifecycle?: import('../services/agents/agent-harness-service').TurnLifecycleListener
}
type SidepanelAgentChatRequest = {
@@ -159,9 +170,12 @@ export function createAgentRoutes(deps: AgentRouteDeps = {}) {
new AgentHarnessService({
browserosServerPort: deps.browserosServerPort,
openclawGateway: deps.openclawGateway,
openclawGatewayChat: deps.openclawGatewayChat,
openclawProvisioner: deps.openclawProvisioner,
hermesGateway: deps.hermesGateway,
})
if (deps.onTurnLifecycle && service instanceof AgentHarnessService) {
service.onTurnLifecycle(deps.onTurnLifecycle)
}
// One checker per route mount. Cached probes refresh every 5min;
// tests can swap in an alternate via deps if needed.
const adapterHealth = deps.adapterHealth ?? new AdapterHealthChecker()
@@ -672,11 +686,14 @@ async function parseCreateAgentBody(c: Context<Env>): Promise<
? record.reasoningEffort.trim()
: undefined
// OpenClaw agents resolve their model from the gateway-side provider
// config rather than from the harness catalog. Skip catalog model
// validation for that adapter; everything else still uses the catalog.
// OpenClaw and Hermes resolve their model from per-agent provider
// config (gateway / config.yaml) rather than from the harness catalog.
// Skip catalog model validation for those adapters — both have an
// empty `models: []` in the catalog by design — everything else still
// uses the catalog for validation.
if (
record.adapter !== 'openclaw' &&
record.adapter !== 'hermes' &&
!isSupportedAgentModel(record.adapter, modelId)
) {
return { error: 'Invalid modelId' }
@@ -913,6 +930,9 @@ function handleAgentRouteError(c: Context<Env>, err: unknown) {
if (err instanceof InvalidAgentUpdateError) {
return c.json({ error: err.message }, 400)
}
if (err instanceof HermesProviderConfigInvalidError) {
return c.json({ error: err.message }, 400)
}
if (err instanceof MessageQueueFullError) {
return c.json({ error: err.message }, 429)
}

View File

@@ -42,12 +42,12 @@ import { createSoulRoutes } from './routes/soul'
import { createStatusRoute } from './routes/status'
import { createTerminalRoutes } from './routes/terminal'
import { GlobalAclPolicyService } from './services/acl/global-acl-policy'
import { getHermesContainerService } from './services/hermes/hermes-container'
import {
connectKlavisInBackground,
type KlavisProxyRef,
} from './services/klavis/strata-proxy'
import { convertOpenClawHistoryToAgentHistory } from './services/openclaw/history-mapper'
import { OpenClawGatewayChatClient } from './services/openclaw/openclaw-gateway-chat-client'
import { getOpenClawService } from './services/openclaw/openclaw-service'
import type { Env, HttpServerConfig } from './types'
import { defaultCorsConfig } from './utils/cors'
@@ -138,16 +138,11 @@ export async function createHttpServer(config: HttpServerConfig) {
browserosServerPort: port,
browser,
openclawGateway: {
getGatewayToken: () => getOpenClawService().getGatewayToken(),
getContainerName: () => OPENCLAW_GATEWAY_CONTAINER_NAME,
getLimaHomeDir: () => getLimaHomeDir(),
getLimactlPath: () => resolveBundledLimactl(resourcesDir),
getVmName: () => VM_NAME,
},
openclawGatewayChat: new OpenClawGatewayChatClient(
() => getOpenClawService().getPort(),
async () => getOpenClawService().getGatewayToken(),
),
openclawProvisioner: {
createAgent: (input) => getOpenClawService().createAgent(input),
removeAgent: (agentId) => getOpenClawService().removeAgent(agentId),
@@ -170,6 +165,15 @@ export async function createHttpServer(config: HttpServerConfig) {
return convertOpenClawHistoryToAgentHistory(agentId, raw)
},
},
onTurnLifecycle: (agent, event) => {
if (agent.adapter !== 'openclaw') return
getOpenClawService().recordAgentTurnEvent(
agent.id,
agent.sessionKey,
event,
)
},
hermesGateway: getHermesContainerService().getAccessor(),
}),
)

View File

@@ -6,6 +6,7 @@
import {
AcpxRuntime,
type HermesGatewayAccessor,
type OpenclawGatewayAccessor,
} from '../../../lib/agents/acpx-runtime'
import {
@@ -24,6 +25,8 @@ import {
type QueuedMessage,
type QueuedMessageAttachment,
} from '../../../lib/agents/message-queue'
import { writeHermesPerAgentProvider } from '../hermes/hermes-paths'
import { getHermesProviderMapping } from '../hermes/hermes-provider-map'
export {
MessageQueueFullError,
@@ -46,7 +49,6 @@ import {
type FilePreview,
} from '../openclaw/file-preview'
import { getHostWorkspaceDir } from '../openclaw/openclaw-env'
import type { OpenClawGatewayChatClient } from '../openclaw/openclaw-gateway-chat-client'
import {
type FileSnapshot,
type ProducedFileRow,
@@ -174,12 +176,39 @@ export interface GatewayStatusSnapshot {
| null
}
/**
* Per-turn event the harness emits to subscribers. Lets services that
* want to track liveness for a specific adapter (e.g. OpenClaw's
* ClawSession dashboard) react to the same stream the chat panel sees,
* without each adapter spawning its own gateway-side observer.
*/
export type TurnLifecycleEvent =
| { type: 'turn_started' }
| { type: 'turn_event'; event: AgentStreamEvent }
| { type: 'turn_ended'; error?: string }
export type TurnLifecycleListener = (
agent: {
id: string
adapter: AgentDefinition['adapter']
sessionKey: string
},
event: TurnLifecycleEvent,
) => void
export class AgentHarnessService {
private readonly agentStore: AgentStore
private readonly runtime: AgentRuntime
private readonly openclawProvisioner: OpenClawProvisioner | null
private readonly turnRegistry: TurnRegistry
private readonly messageQueue: FileMessageQueue
private readonly turnLifecycleListeners = new Set<TurnLifecycleListener>()
/**
* Optional override for the BrowserOS dir used by Hermes per-agent
* provider config writes. Defaults to the global `getBrowserosDir()`
* lookup at write time when undefined; tests can inject a tmp dir.
*/
private readonly browserosDir: string | undefined
/**
* Lazy-initialised so tests that swap in a fake `agentStore` don't
* eagerly hit `getDb()` (which throws when the test harness hasn't
@@ -203,9 +232,10 @@ export class AgentHarnessService {
agentStore?: AgentStore
runtime?: AgentRuntime
browserosServerPort?: number
browserosDir?: string
openclawGateway?: OpenclawGatewayAccessor
openclawGatewayChat?: OpenClawGatewayChatClient
openclawProvisioner?: OpenClawProvisioner
hermesGateway?: HermesGatewayAccessor
turnRegistry?: TurnRegistry
messageQueue?: FileMessageQueue
producedFilesStore?: ProducedFilesStore
@@ -217,11 +247,12 @@ export class AgentHarnessService {
new AcpxRuntime({
browserosServerPort: deps.browserosServerPort,
openclawGateway: deps.openclawGateway,
openclawGatewayChat: deps.openclawGatewayChat,
hermesGateway: deps.hermesGateway,
})
this.openclawProvisioner = deps.openclawProvisioner ?? null
this.turnRegistry = deps.turnRegistry ?? new TurnRegistry()
this.messageQueue = deps.messageQueue ?? new FileMessageQueue()
this.browserosDir = deps.browserosDir
if (deps.producedFilesStore) {
this.explicitProducedFilesStore = deps.producedFilesStore
}
@@ -348,6 +379,39 @@ export class AgentHarnessService {
}
}
/**
* Subscribe to turn lifecycle events for every running agent. Returns
* an unsubscribe function. Listeners are best-effort: a throwing
* listener does not break the turn.
*/
onTurnLifecycle(listener: TurnLifecycleListener): () => void {
this.turnLifecycleListeners.add(listener)
return () => this.turnLifecycleListeners.delete(listener)
}
private emitTurnLifecycle(
agent: AgentDefinition,
event: TurnLifecycleEvent,
): void {
if (this.turnLifecycleListeners.size === 0) return
const summary = {
id: agent.id,
adapter: agent.adapter,
sessionKey: agent.sessionKey,
}
for (const listener of this.turnLifecycleListeners) {
try {
listener(summary, event)
} catch (err) {
logger.warn('Turn lifecycle listener threw', {
agentId: agent.id,
eventType: event.type,
error: err instanceof Error ? err.message : String(err),
})
}
}
}
/** Mark `agentId` as actively running a turn. */
notifyTurnStarted(agentId: string): void {
this.activity.set(agentId, { status: 'working', lastEventAt: Date.now() })
@@ -482,8 +546,24 @@ export class AgentHarnessService {
}
async createAgent(input: CreateAgentInput): Promise<AgentDefinition> {
if (input.adapter === 'hermes') {
// Validate before touching the store so we don't leave an orphan
// record on the unhappy path.
assertHermesProviderInputValid(input)
}
const agent = await this.agentStore.create(input)
if (agent.adapter === 'hermes') {
try {
await this.writeHermesPerAgentProvider(agent.id, input)
} catch (err) {
await this.agentStore.delete(agent.id).catch(() => {})
throw err
}
return agent
}
if (agent.adapter !== 'openclaw') {
return agent
}
@@ -524,6 +604,35 @@ export class AgentHarnessService {
}
}
/**
* Write Hermes' per-agent config.yaml + .env into the on-host home
* dir. Caller must have already run assertHermesProviderInputValid;
* any throw here is a real I/O failure and must roll back the agent
* record.
*/
private async writeHermesPerAgentProvider(
agentId: string,
input: CreateAgentInput,
): Promise<void> {
// Non-null assertions are safe: assertHermesProviderInputValid ran
// first and rejects when any required field is missing.
const mapping = getHermesProviderMapping(input.providerType as string)
if (!mapping) {
throw new HermesProviderConfigInvalidError(
`Provider type "${input.providerType}" is not supported by Hermes`,
)
}
await writeHermesPerAgentProvider({
browserosDir: this.browserosDir,
agentId,
providerId: mapping.hermesProvider,
envVarName: mapping.envVarName,
apiKey: (input.apiKey as string).trim(),
modelId: (input.modelId as string).trim(),
baseUrl: input.baseUrl?.trim() || undefined,
})
}
/**
* Pulls every gateway-side OpenClaw agent into the harness store as a
* harness record (idempotent, safe to call repeatedly). This lets
@@ -764,6 +873,7 @@ export class AgentHarnessService {
prompt: input.message,
})
this.notifyTurnStarted(agent.id)
this.emitTurnLifecycle(agent, { type: 'turn_started' })
// Kick off the runtime call in the background. The per-turn
// AbortController — NOT the HTTP request signal — is what cancels
@@ -903,6 +1013,7 @@ export class AgentHarnessService {
if (done) break
if (value.type === 'error') lastErrorMessage = value.message
this.turnRegistry.pushEvent(turnId, value)
this.emitTurnLifecycle(agent, { type: 'turn_event', event: value })
}
} finally {
try {
@@ -964,6 +1075,10 @@ export class AgentHarnessService {
ok: lastErrorMessage === undefined,
error: lastErrorMessage,
})
this.emitTurnLifecycle(agent, {
type: 'turn_ended',
error: lastErrorMessage,
})
}
}
@@ -1130,6 +1245,48 @@ export class InvalidAgentUpdateError extends Error {
}
}
/**
* Thrown when a Hermes adapter agent is created without a complete
* provider config (provider type, API key, model id; base URL when the
* provider mapping requires it). Surfaces as a 400 in the route layer.
*/
export class HermesProviderConfigInvalidError extends Error {
constructor(message: string) {
super(message)
this.name = 'HermesProviderConfigInvalidError'
}
}
function assertHermesProviderInputValid(input: CreateAgentInput): void {
const providerType = input.providerType?.trim()
if (!providerType) {
throw new HermesProviderConfigInvalidError(
'Hermes agent requires providerType (pick a provider configured in BrowserOS AI Settings)',
)
}
const mapping = getHermesProviderMapping(providerType)
if (!mapping) {
throw new HermesProviderConfigInvalidError(
`Provider type "${providerType}" is not supported by Hermes`,
)
}
if (!input.apiKey?.trim()) {
throw new HermesProviderConfigInvalidError(
'Hermes agent requires apiKey from the selected provider',
)
}
if (!input.modelId?.trim()) {
throw new HermesProviderConfigInvalidError(
'Hermes agent requires modelId from the selected provider',
)
}
if (mapping.requiresBaseUrl && !input.baseUrl?.trim()) {
throw new HermesProviderConfigInvalidError(
`Provider type "${providerType}" requires baseUrl`,
)
}
}
/**
* Thrown when `startTurn` is called for an agent that already has an
* in-flight turn. The route layer maps this to 409 + the existing

View File

@@ -0,0 +1,379 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Lifecycle service for the Hermes ACPX adapter container. Hermes runs
* in the same Lima VM as OpenClaw — image is pulled into containerd, an
* idle container is kept up so the harness can `nerdctl exec hermes acp`
* per turn. Much smaller than OpenClawService: no gateway, no token, no
* agent CRUD via container — the harness owns all of that.
*/
import { mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import {
HERMES_CONTAINER_HARNESS_DIR,
HERMES_CONTAINER_NAME,
HERMES_IMAGE,
} from '@browseros/shared/constants/hermes'
import { getBrowserosDir } from '../../../lib/browseros-dir'
import {
ContainerCli,
type ContainerSpec,
ImageLoader,
} from '../../../lib/container'
import { logger } from '../../../lib/logger'
import { withProcessLock } from '../../../lib/process-lock'
import {
GUEST_VM_STATE,
getLimaHomeDir,
resolveBundledLimactl,
resolveBundledLimaTemplate,
VM_NAME,
VmRuntime,
} from '../../../lib/vm'
import { ContainerNameInUseError } from '../../../lib/vm/errors'
import { getHermesHarnessHostDir, getHermesHostStateDir } from './hermes-paths'
const CREATE_CONTAINER_MAX_ATTEMPTS = 3
const NAME_RELEASE_WAIT_MS = 10_000
const UNSUPPORTED_PLATFORM_MESSAGE =
'browseros-vm currently supports macOS only; see the Linux/Windows tracking issue'
export interface HermesContainerServiceConfig {
resourcesDir?: string
browserosDir?: string
}
export interface HermesAccessor {
getContainerName(): string
getLimaHomeDir(): string
getLimactlPath(): string
getVmName(): string
}
export class HermesContainerService {
private vm: VmRuntime | null = null
private shell: ContainerCli | null = null
private loader: ImageLoader | null = null
private limactlPath: string
private limaHome: string
private resourcesDir: string | null
private browserosDir: string
private readonly hermesStateDir: string
private readonly platform: NodeJS.Platform
private lifecycleLock: Promise<void> = Promise.resolve()
constructor(config: HermesContainerServiceConfig = {}) {
this.resourcesDir = config.resourcesDir ?? null
this.browserosDir = config.browserosDir ?? getBrowserosDir()
this.hermesStateDir = getHermesHostStateDir(this.browserosDir)
this.platform = process.platform
this.limactlPath = this.resolveLimactlPath()
this.limaHome = getLimaHomeDir(this.browserosDir)
this.initRuntimes()
}
configure(config: HermesContainerServiceConfig): void {
let runtimeChanged = false
if (
config.resourcesDir !== undefined &&
config.resourcesDir !== this.resourcesDir
) {
this.resourcesDir = config.resourcesDir
runtimeChanged = true
}
if (
config.browserosDir !== undefined &&
config.browserosDir !== this.browserosDir
) {
this.browserosDir = config.browserosDir
runtimeChanged = true
}
if (runtimeChanged) {
this.limactlPath = this.resolveLimactlPath()
this.limaHome = getLimaHomeDir(this.browserosDir)
this.initRuntimes()
}
}
/** Warm the VM and Hermes image so first-use spawns avoid registry work. */
async prewarm(onLog?: (msg: string) => void): Promise<void> {
if (!this.isSupportedPlatform()) {
logger.warn('Hermes prewarm skipped: unsupported platform', {
platform: this.platform,
})
return
}
return this.withLifecycleLock('prewarm', async () => {
const logProgress = (message: string) => {
logger.info(message)
onLog?.(message)
}
logProgress('Hermes prewarm: ensuring BrowserOS VM is ready')
await this.requireVm().ensureReady()
logProgress(`Hermes prewarm: ensuring image ${HERMES_IMAGE} is available`)
await this.requireLoader().ensureImageLoaded(HERMES_IMAGE)
logProgress('Hermes prewarm: ready')
})
}
/**
* Start a long-running idle container with the harness dir bind-
* mounted. The container's default ENTRYPOINT (`hermes acp`) is
* overridden with `tini -- sleep infinity` so the container stays
* up; the AcpxRuntime spawns `hermes acp` per turn via `nerdctl
* exec` and pipes its stdio back through limactl/SSH.
*/
async start(onLog?: (msg: string) => void): Promise<void> {
if (!this.isSupportedPlatform()) {
logger.warn('Hermes start skipped: unsupported platform', {
platform: this.platform,
})
return
}
return this.withLifecycleLock('start', async () => {
const logProgress = (msg: string) => {
logger.info(msg)
onLog?.(msg)
}
await this.requireVm().ensureReady(logProgress)
await this.requireLoader().ensureImageLoaded(HERMES_IMAGE, logProgress)
// Make sure the host-side harness root exists so the bind-mount
// doesn't error on a missing source path. Per-agent home dirs
// get created lazily by prepareHermesContext.
await mkdir(getHermesHarnessHostDir(this.browserosDir), {
recursive: true,
})
logProgress('Hermes: starting idle container...')
const container = await this.buildContainerSpec()
await this.createContainerWithNameReconcile(container, logProgress)
await this.requireShell().startContainer(container.name, logProgress)
logProgress(
`Hermes container running: ${HERMES_CONTAINER_NAME} (image ${HERMES_IMAGE})`,
)
})
}
async stop(): Promise<void> {
if (!this.isSupportedPlatform()) return
return this.withLifecycleLock('stop', async () => {
logger.info('Stopping Hermes container', {
container: HERMES_CONTAINER_NAME,
})
await this.requireShell().removeContainer(
HERMES_CONTAINER_NAME,
{ force: true },
undefined,
)
})
}
async restart(onLog?: (msg: string) => void): Promise<void> {
await this.stop()
await this.start(onLog)
}
async shutdown(): Promise<void> {
if (!this.isSupportedPlatform()) return
try {
await this.requireShell().removeContainer(
HERMES_CONTAINER_NAME,
{ force: true },
undefined,
)
} catch {
// best effort
}
}
/**
* Live-getters used by AcpxRuntime to spawn `hermes acp` inside the
* container. Returned shape matches `HermesGatewayAccessor` in
* acpx-runtime — kept structural here so the type wiring works without
* a circular import.
*/
getAccessor(): HermesAccessor {
return {
getContainerName: () => HERMES_CONTAINER_NAME,
getLimaHomeDir: () => this.limaHome,
getLimactlPath: () => this.limactlPath,
getVmName: () => VM_NAME,
}
}
// ── Internal ─────────────────────────────────────────────────────────
private isSupportedPlatform(): boolean {
return this.platform === 'darwin'
}
private resolveLimactlPath(): string {
if (!this.isSupportedPlatform()) return 'limactl'
return this.resourcesDir
? resolveBundledLimactl(this.resourcesDir)
: 'limactl'
}
private initRuntimes(): void {
if (!this.isSupportedPlatform()) {
this.vm = null
this.shell = null
this.loader = null
return
}
this.vm = new VmRuntime({
limactlPath: this.limactlPath,
limaHome: this.limaHome,
templatePath: this.resourcesDir
? resolveBundledLimaTemplate(this.resourcesDir)
: undefined,
browserosRoot: this.browserosDir,
})
this.shell = new ContainerCli({
limactlPath: this.limactlPath,
limaHome: this.limaHome,
vmName: VM_NAME,
})
this.loader = new ImageLoader(this.shell)
}
private requireVm(): VmRuntime {
if (!this.vm) throw unsupportedPlatformError()
return this.vm
}
private requireShell(): ContainerCli {
if (!this.shell) throw unsupportedPlatformError()
return this.shell
}
private requireLoader(): ImageLoader {
if (!this.loader) throw unsupportedPlatformError()
return this.loader
}
private async buildContainerSpec(): Promise<ContainerSpec> {
const guestHarnessDir = `${GUEST_VM_STATE}/hermes/harness`
const gateway = await this.requireVm().getDefaultGateway()
return {
name: HERMES_CONTAINER_NAME,
image: HERMES_IMAGE,
restart: 'unless-stopped',
env: {
PYTHONUNBUFFERED: '1',
},
// Make `host.containers.internal` resolve to the VM's gateway so
// hermes inside the container can reach the BrowserOS HTTP server
// running on the host (where the BrowserOS MCP /mcp lives). Mirrors
// OpenClaw's container-runtime.ts gatewayContainer setup.
addHosts: [`host.containers.internal:${gateway}`],
mounts: [
// Host harness root lives under <browserosDir>/vm/hermes/harness
// so it's reachable inside the Lima VM via the existing vm/
// mount; container sees it at /data/agents/harness.
{ source: guestHarnessDir, target: HERMES_CONTAINER_HARNESS_DIR },
],
// Override the upstream image's `hermes acp` ENTRYPOINT — we want
// a long-lived container that we `nerdctl exec` into per turn,
// not one that tries to speak ACP at startup.
// Bypass tini and use /bin/sh directly: tini 0.19.0 in the upstream
// image getopt-parses any `-x` token (even after the PROGRAM), so
// `tini /bin/sh -c "..."` errors with `invalid option -- 'c'`. We
// don't need tini reaping zombies for an idle sleeper anyway.
entrypoint: '/bin/sh',
command: ['-c', 'exec sleep infinity'],
}
}
/**
* Create the fixed-name Hermes container, reconciling stale nerdctl
* name ownership. Mirrors the OpenClaw service's reconcile loop.
*/
private async createContainerWithNameReconcile(
container: ContainerSpec,
onLog?: (msg: string) => void,
): Promise<void> {
let attempt = 1
const shell = this.requireShell()
while (true) {
await this.removeContainerAndWait(container.name)
try {
await shell.createContainer(container, onLog)
return
} catch (err) {
if (
!(err instanceof ContainerNameInUseError) ||
attempt >= CREATE_CONTAINER_MAX_ATTEMPTS
) {
throw err
}
logger.warn('Hermes container name still in use; retrying create', {
containerName: container.name,
attempt,
maxAttempts: CREATE_CONTAINER_MAX_ATTEMPTS,
})
attempt++
}
}
}
private async removeContainerAndWait(containerName: string): Promise<void> {
const shell = this.requireShell()
await shell.removeContainer(containerName, { force: true }, undefined)
await shell.waitForContainerNameRelease(containerName, {
timeoutMs: NAME_RELEASE_WAIT_MS,
intervalMs: 100,
})
}
private async withLifecycleLock<T>(
operation: string,
fn: () => Promise<T>,
): Promise<T> {
const previous = this.lifecycleLock
let release!: () => void
this.lifecycleLock = new Promise<void>((resolve) => {
release = resolve
})
await previous.catch(() => undefined)
try {
return await withProcessLock(
'hermes-lifecycle',
{ lockDir: join(this.hermesStateDir, '.locks') },
async () => {
logger.debug('Hermes lifecycle operation started', { operation })
return await fn()
},
)
} finally {
release()
}
}
}
function unsupportedPlatformError(): Error {
return new Error(UNSUPPORTED_PLATFORM_MESSAGE)
}
let service: HermesContainerService | null = null
export function configureHermesContainerService(
config: HermesContainerServiceConfig,
): HermesContainerService {
if (!service) {
service = new HermesContainerService(config)
return service
}
service.configure(config)
return service
}
export function getHermesContainerService(): HermesContainerService {
if (!service) service = new HermesContainerService()
return service
}

View File

@@ -0,0 +1,90 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Host-side path helpers for the Hermes container.
*
* Hermes per-agent state lives under the BrowserOS-managed VM state
* directory (so it's reachable inside the Lima VM via the existing
* vm/ → /mnt/browseros/vm bind mount). The Hermes container then bind-
* mounts the guest-side path (/mnt/browseros/vm/hermes/harness) into
* /data/agents/harness, so `HERMES_HOME` ends up pointing at a path
* the container can actually open.
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { getVmStateDir } from '../../../lib/browseros-dir'
/** Top-level Hermes state directory: `<browserosDir>/vm/hermes`. */
export function getHermesHostStateDir(browserosDir?: string): string {
return join(
browserosDir ? join(browserosDir, 'vm') : getVmStateDir(),
'hermes',
)
}
/** Per-agent harness root: `<browserosDir>/vm/hermes/harness`. */
export function getHermesHarnessHostDir(browserosDir?: string): string {
return join(getHermesHostStateDir(browserosDir), 'harness')
}
/**
* Per-agent home directory on the host. The Hermes container reads
* `config.yaml` + `.env` from here via the harness bind mount; both
* files are written at agent-create time by AgentHarnessService and
* stay constant across turns.
*/
export function getHermesAgentHomeHostDir(input: {
browserosDir?: string
agentId: string
}): string {
return join(
getHermesHarnessHostDir(input.browserosDir),
input.agentId,
'home',
)
}
/**
* Write a Hermes per-agent provider config into the on-host home dir.
* The dir lives under <browserosDir>/vm/hermes/harness/<agentId>/home/
* which is bind-mounted into the container at /data/agents/harness/<id>/home/.
*
* Idempotent: writes always overwrite (last-write-wins). The provider
* id, env var name, and credentials must be supplied by the caller —
* Hermes agents always carry their own config; there is no
* `~/.hermes/` fallback.
*/
export async function writeHermesPerAgentProvider(input: {
browserosDir?: string
agentId: string
providerId: string
envVarName: string
apiKey: string
modelId: string
baseUrl?: string
}): Promise<void> {
const home = getHermesAgentHomeHostDir({
browserosDir: input.browserosDir,
agentId: input.agentId,
})
await mkdir(home, { recursive: true })
const yamlLines = [
'model:',
` default: ${JSON.stringify(input.modelId)}`,
` provider: ${JSON.stringify(input.providerId)}`,
]
if (input.baseUrl) {
yamlLines.push(` base_url: ${JSON.stringify(input.baseUrl)}`)
}
yamlLines.push('')
await writeFile(join(home, 'config.yaml'), yamlLines.join('\n'), {
mode: 0o600,
})
const envLines: string[] = [`${input.envVarName}=${input.apiKey}`, '']
await writeFile(join(home, '.env'), envLines.join('\n'), { mode: 0o600 })
}

View File

@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Translation table from BrowserOS LLM provider types (the values that
* live in `LlmProviderConfig.type` on the extension side) to Hermes
* runtime configuration. Hermes itself only knows a small fixed set of
* provider keys; BrowserOS exposes a richer registry, so we explicitly
* gate which BrowserOS provider types Hermes can consume.
*
* The set of allowed BrowserOS provider types is shared with the
* frontend via `HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES`. Adding a
* new type there without an entry here will fail the type check below
* (every supported type must have a mapping).
*
* Anything not listed is rejected at agent-create time with a clear
* error — there is no `~/.hermes/` fallback.
*/
import {
HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES,
type HermesSupportedBrowserosProviderType,
} from '@browseros/shared/constants/hermes'
export interface HermesProviderMapping {
/** Hermes' own provider key written into `model.provider` in config.yaml. */
hermesProvider: string
/** Env var Hermes reads the API key from (written into per-agent `.env`). */
envVarName: string
/** True when Hermes needs an explicit `model.base_url` to reach the API. */
requiresBaseUrl: boolean
}
const HERMES_PROVIDER_MAP: Record<
HermesSupportedBrowserosProviderType,
HermesProviderMapping
> = {
anthropic: {
hermesProvider: 'anthropic',
envVarName: 'ANTHROPIC_API_KEY',
requiresBaseUrl: false,
},
openai: {
hermesProvider: 'openai',
envVarName: 'OPENAI_API_KEY',
requiresBaseUrl: false,
},
openrouter: {
hermesProvider: 'openrouter',
envVarName: 'OPENROUTER_API_KEY',
requiresBaseUrl: false,
},
// BrowserOS' "openai-compatible" type is anything that speaks the OpenAI
// wire format on a custom base URL (Together, Groq, etc.). Hermes routes
// these through its `openai` provider with `base_url` set.
'openai-compatible': {
hermesProvider: 'openai',
envVarName: 'OPENAI_API_KEY',
requiresBaseUrl: true,
},
}
export function isHermesSupportedProviderType(
providerType: string,
): providerType is HermesSupportedBrowserosProviderType {
return (
HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES as readonly string[]
).includes(providerType)
}
export function getHermesProviderMapping(
providerType: string,
): HermesProviderMapping | undefined {
if (!isHermesSupportedProviderType(providerType)) return undefined
return HERMES_PROVIDER_MAP[providerType]
}

View File

@@ -52,7 +52,6 @@ export type GatewayContainerSpec = {
hostPort: number
hostHome: string
envFilePath: string
gatewayToken?: string
timezone: string
}
@@ -414,9 +413,7 @@ export class ContainerRuntime {
TZ: input.timezone,
PATH: GATEWAY_PATH,
NPM_CONFIG_PREFIX: GATEWAY_NPM_PREFIX,
...(input.gatewayToken
? { OPENCLAW_GATEWAY_TOKEN: input.gatewayToken }
: {}),
OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH: '1',
}
}

View File

@@ -21,6 +21,7 @@
* resulting AgentHistoryToolCall has both input and output.
*/
import { unwrapBrowserosAcpUserMessage } from '../../../lib/agents/acpx-runtime'
import type {
AgentHistoryEntry,
AgentHistoryToolCall,
@@ -35,40 +36,56 @@ const CRON_PROMPT_PREFIX_PATTERN =
/^\[cron:[0-9a-f-]+ ([^\]]+)\]\s*([\s\S]*?)\n*Current time:[^\n]*(?:\n[\s\S]*)?$/
const CRON_DELIVERY_TRAILER =
/\n*Use the message tool if you need to notify the user directly[\s\S]*$/
const BROWSEROS_WORKING_DIR_PREFIX = /^\[Working directory:[^\]]*\]\n+/
const BROWSEROS_ROLE_BLOCK = /<role>[\s\S]*?<\/role>\n+/
const BROWSEROS_USER_REQUEST_BLOCK =
/<user_request>\n?([\s\S]*?)\n?<\/user_request>/
const BROWSEROS_SYSTEM_REMINDER_BLOCK =
/\n*<system-reminder>[\s\S]*?<\/system-reminder>\s*$/
const QUEUED_MARKER_LINE =
/^\[Queued user message that arrived while the previous turn was still active\]\s*$/m
const SUBAGENT_CONTEXT_PREFIX = /^\[Subagent Context\][\s\S]*$/
// Emitted by OpenClaw's acp-cli ahead of the BrowserOS envelope. Three
// prefix shapes (any combination, in this stack order):
//
// 1. `[media attached: <internal-path> (<mime>)]` ← per attachment
// 2. `[<weekday> <YYYY-MM-DD HH:MM> <TZ>]` ← injectTimestamp
// 3. `[Working directory: <path>]` ← acp-cli prefixCwd
//
// Stacks #1 may appear multiple times (one per image). Stack #2 and #3
// can render on the same line separated by a space. Each known prefix is
// anchored on its content shape (not just `[…]`) to avoid clobbering
// user-typed lines that happen to start with a bracket.
const OPENCLAW_MEDIA_PREFIX_LINE = /^\[media attached:[^\]\n]*\]\n/
const OPENCLAW_TIMESTAMP_PREFIX =
/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) \d{4}-\d{2}-\d{2} \d{2}:\d{2}[^\]\n]*\][ \t]*/
const OPENCLAW_WORKDIR_PREFIX = /^\[Working directory: [^\]\n]*\]\n+/
function stripOpenClawAcpCliEnvelope(value: string): string {
let s = value
while (OPENCLAW_MEDIA_PREFIX_LINE.test(s)) {
s = s.replace(OPENCLAW_MEDIA_PREFIX_LINE, '')
}
s = s.replace(OPENCLAW_TIMESTAMP_PREFIX, '')
s = s.replace(OPENCLAW_WORKDIR_PREFIX, '')
return s
}
/**
* Strip OpenClaw + BrowserOS scaffolding from a "user" message before
* showing it in the chat panel. The raw prompts contain:
* showing it in the chat panel.
*
* - OpenClaw cron payload prefix:
* `[cron:<uuid> <name>] <payload>\nCurrent time: ...\nUse the
* message tool if you need to notify the user directly...`
* - BrowserOS ACP prefix:
* `[Working directory: ...]\n\n<role>...</role>\n\n<user_request>
* <actual user text>\n</user_request>\n\n<system-reminder>...</system-reminder>`
* - Queued-marker concatenation: when multiple prompts queue while a
* turn is active, BrowserOS joins them with the marker line
* `[Queued user message that arrived while the previous turn was
* still active]`. We split on those markers and clean each chunk
* independently, then re-join the non-empty results.
* - Subagent context prefix: when an agent invokes a nested subagent,
* OpenClaw seeds the subagent's session with `[Subagent Context]
* You are running as a subagent (depth N/M). ...` followed by
* internal task framing. The actual task lives in the system prompt;
* this user message is pure scaffolding and gets dropped entirely.
* BrowserOS-side envelope (`<role>…</role>\n\n<user_request>…</user_request>`)
* is delegated to `unwrapBrowserosAcpUserMessage`, which performs an
* exact-string match against the same constants `buildBrowserosAcpPrompt`
* uses to wrap. Matcher and wrapper live in the same repo, so the two
* always travel together.
*
* For each, we extract just the user-facing text. Non-matching messages
* fall through unchanged so any future pattern we don't recognize stays
* visible rather than getting silently dropped.
* OpenClaw's acp-cli prepends a `[Working directory: <path>]\n\n` line
* before the BrowserOS envelope (see /app/dist/acp-cli-*.js, line 1361).
* We strip that single line up-front so the `^<role>` anchor in
* `unwrapBrowserosAcpUserMessage` matches.
*
* OpenClaw-injected scaffolding (cron prefix, queued-marker, subagent
* context) is still pattern-matched here. Removing those requires either
* an OpenClaw schema change exposing the structured trigger payload, or a
* BrowserOS-side side-channel (cache cron payloads on `cron.add` and look
* up by jobId). Tracked as the next cleanup; until then this is best-
* effort with text-level patterns.
*/
export function cleanHistoryUserText(raw: string): string {
if (!raw) return raw
@@ -102,15 +119,11 @@ function cleanSingleUserMessage(raw: string): string {
const payload = cronMatch[2] ?? ''
return payload.replace(CRON_DELIVERY_TRAILER, '').trim()
}
let text = trimmed
text = text.replace(BROWSEROS_WORKING_DIR_PREFIX, '')
text = text.replace(BROWSEROS_ROLE_BLOCK, '')
text = text.replace(BROWSEROS_SYSTEM_REMINDER_BLOCK, '')
const userReq = BROWSEROS_USER_REQUEST_BLOCK.exec(text)
if (userReq) {
return (userReq[1] ?? '').trim()
}
return text.trim()
// Strip OpenClaw's acp-cli envelope (media-attached lines + timestamp
// + workdir) before delegating, so the BrowserOS unwrap helper's
// `^<role>` anchor matches.
const withoutEnvelope = stripOpenClawAcpCliEnvelope(trimmed)
return unwrapBrowserosAcpUserMessage(withoutEnvelope).trim()
}
type RichBlock =

View File

@@ -1,211 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Minimal OpenAI-compatible chat client against the OpenClaw gateway.
* Used exclusively by the harness's image carve-out: when the user
* attaches images to an OpenClaw agent, the harness diverts the turn
* here instead of through the ACP bridge (which silently drops image
* content blocks). The gateway's `/v1/chat/completions` endpoint
* accepts OpenAI-style multimodal `image_url` parts.
*
* Output is normalized to `AgentStreamEvent` so the rest of the harness
* pipeline (UI streaming, history persistence) doesn't care that the
* transport is HTTP rather than ACP for this turn.
*/
import type { AgentStreamEvent } from '../../../lib/agents/types'
import { logger } from '../../../lib/logger'
export type OpenAIContentPart =
| { type: 'text'; text: string }
| { type: 'image_url'; image_url: { url: string } }
export interface OpenAIChatMessage {
role: 'system' | 'user' | 'assistant'
content: string | OpenAIContentPart[]
}
export interface GatewayChatTurnInput {
/** Gateway-side agent name. Equal to the harness id post Step 9 backfill. */
agentId: string
sessionKey: string
messages: OpenAIChatMessage[]
signal?: AbortSignal
}
export class OpenClawGatewayChatClient {
constructor(
private readonly getHostPort: () => number,
private readonly getToken: () => Promise<string>,
) {}
async streamTurn(
input: GatewayChatTurnInput,
): Promise<ReadableStream<AgentStreamEvent>> {
const token = await this.getToken()
const response = await fetch(
`http://127.0.0.1:${this.getHostPort()}/v1/chat/completions`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: resolveAgentModel(input.agentId),
stream: true,
messages: input.messages,
user: `browseros:${input.agentId}:${input.sessionKey}`,
}),
signal: input.signal,
},
)
if (!response.ok) {
const detail = await response.text().catch(() => '')
throw new Error(
detail || `OpenClaw gateway chat failed with status ${response.status}`,
)
}
const body = response.body
if (!body) {
throw new Error('OpenClaw gateway chat response had no body')
}
return new ReadableStream<AgentStreamEvent>({
start(controller) {
void pumpOpenAIChunks(body, controller, input.signal)
},
})
}
}
function resolveAgentModel(agentId: string): string {
// The gateway routes `openclaw` → its default `main` provider config,
// and `openclaw/<agentId>` → the per-agent provider config. Backfilled
// legacy agents (`main`, orphans) can use the unprefixed form.
return agentId === 'main' ? 'openclaw' : `openclaw/${agentId}`
}
async function pumpOpenAIChunks(
body: ReadableStream<Uint8Array>,
controller: ReadableStreamDefaultController<AgentStreamEvent>,
signal?: AbortSignal,
): Promise<void> {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let closed = false
let aborted = false
let stopReason: string | undefined
// Re-emit explicit signal aborts as a clean cancel rather than letting
// the underlying `reader.read()` reject — keeps the controller in a
// sensible state if the caller bails (e.g. tab close).
const onAbort = () => {
aborted = true
void reader.cancel().catch(() => {})
}
signal?.addEventListener('abort', onAbort, { once: true })
const flushLine = (line: string) => {
if (closed || !line.startsWith('data:')) return
const payload = line.slice(5).trim()
if (!payload || payload === '[DONE]') {
finish()
return
}
let parsed: unknown
try {
parsed = JSON.parse(payload)
} catch {
controller.enqueue({
type: 'error',
message: 'Failed to parse OpenClaw gateway chunk',
})
finish()
return
}
const text = extractDeltaText(parsed)
if (text) {
controller.enqueue({
type: 'text_delta',
text,
stream: 'output',
rawType: 'agent_message_chunk',
})
}
const finishReason = extractFinishReason(parsed)
if (finishReason) {
stopReason = finishReason === 'stop' ? 'end_turn' : finishReason
finish()
}
}
const finish = () => {
if (closed) return
closed = true
controller.enqueue({ type: 'done', stopReason: stopReason ?? 'end_turn' })
controller.close()
}
try {
while (true) {
if (aborted) {
if (!closed) {
closed = true
controller.close()
}
return
}
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let idx = buffer.indexOf('\n\n')
while (idx >= 0) {
const event = buffer.slice(0, idx)
buffer = buffer.slice(idx + 2)
for (const line of event.split('\n')) flushLine(line)
if (closed) return
idx = buffer.indexOf('\n\n')
}
}
if (!closed) {
// Stream ended without an explicit [DONE]. Treat as natural end.
finish()
}
} catch (err) {
if (closed || aborted) return
logger.warn('OpenClaw gateway chat stream errored', {
error: err instanceof Error ? err.message : String(err),
})
controller.enqueue({
type: 'error',
message: err instanceof Error ? err.message : String(err),
})
closed = true
controller.close()
} finally {
signal?.removeEventListener('abort', onAbort)
reader.releaseLock()
}
}
interface OpenAIStreamChunk {
choices?: Array<{
delta?: { content?: unknown }
finish_reason?: string | null
}>
}
function extractDeltaText(value: unknown): string {
const chunk = value as OpenAIStreamChunk
const content = chunk?.choices?.[0]?.delta?.content
return typeof content === 'string' ? content : ''
}
function extractFinishReason(value: unknown): string | null {
const chunk = value as OpenAIStreamChunk
return chunk?.choices?.[0]?.finish_reason ?? null
}

View File

@@ -92,10 +92,7 @@ export type OpenClawSessionHistoryEvent =
| { type: 'error'; data: { message: string } }
export class OpenClawHttpClient {
constructor(
private readonly hostPort: number,
private readonly getToken: () => Promise<string>,
) {}
constructor(private readonly hostPort: number) {}
async getSessionHistory(
sessionKey: string,
@@ -121,15 +118,9 @@ export class OpenClawHttpClient {
async isAuthenticated(): Promise<boolean> {
try {
const token = await this.getToken()
const response = await fetch(
`http://127.0.0.1:${this.hostPort}/v1/models`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
},
{ method: 'GET' },
)
return response.ok
} catch {
@@ -142,15 +133,11 @@ export class OpenClawHttpClient {
input: OpenClawSessionHistoryInput,
extraHeaders: Record<string, string>,
): Promise<Response> {
const token = await this.getToken()
const response = await fetch(
`http://127.0.0.1:${this.hostPort}${buildHistoryPath(sessionKey, input)}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
...extraHeaders,
},
headers: extraHeaders,
signal: input.signal,
},
)

View File

@@ -1,276 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Connects to the OpenClaw gateway's WebSocket control plane and pipes
* chat broadcast events into a ClawSession state machine. The observer
* is a transport layer only — it handles the WS connection lifecycle
* (connect, handshake, reconnect) and delegates all state management
* to ClawSession.
*/
import WebSocket from 'ws'
import { logger } from '../../../lib/logger'
import type { ClawSession } from './claw-session'
// ---------------------------------------------------------------------------
// Protocol types (subset of OpenClaw gateway protocol v3)
// ---------------------------------------------------------------------------
const PROTOCOL_VERSION = 3
const HANDSHAKE_REQUEST_ID = 'connect'
const RECONNECT_DELAY_MS = 5_000
const CONNECT_TIMEOUT_MS = 10_000
interface RequestFrame {
type: 'req'
id: string
method: string
params: Record<string, unknown>
}
type IncomingFrame =
| { type: 'res'; id: string; ok: true; payload?: unknown }
| {
type: 'res'
id: string
ok: false
error: { code: string; message: string }
}
| { type: 'event'; event: string; payload?: unknown }
// ---------------------------------------------------------------------------
// Observer
// ---------------------------------------------------------------------------
export class OpenClawObserver {
private ws: WebSocket | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private connected = false
private closed = false
private gatewayUrl: string | null = null
private gatewayToken: string | null = null
constructor(private readonly session: ClawSession) {}
/** Start observing the gateway at the given URL with the given token. */
connect(gatewayUrl: string, token: string): void {
this.gatewayUrl = gatewayUrl
this.gatewayToken = token
this.closed = false
this.doConnect()
}
/** Stop observing and close the WebSocket. */
disconnect(): void {
this.closed = true
this.clearReconnect()
if (this.ws) {
try {
this.ws.close()
} catch {}
this.ws = null
}
this.connected = false
}
/** Whether the observer has an active WS connection. */
isConnected(): boolean {
return this.connected
}
// ── Private ─────────────────────────────────────────────────────────
private doConnect(): void {
if (this.closed || !this.gatewayUrl || !this.gatewayToken) return
const wsUrl = this.gatewayUrl
.replace(/^http:\/\//, 'ws://')
.replace(/^https:\/\//, 'wss://')
logger.debug('OpenClaw observer connecting', { url: wsUrl })
const ws = new WebSocket(wsUrl)
this.ws = ws
const connectTimeout = setTimeout(() => {
logger.warn('OpenClaw observer handshake timeout')
ws.terminate()
}, CONNECT_TIMEOUT_MS)
let handshakeSent = false
ws.on('message', (raw) => {
let frame: IncomingFrame
try {
frame = JSON.parse(raw.toString('utf8')) as IncomingFrame
} catch {
return
}
// The gateway sends a connect.challenge event before accepting
// the connect request. Send the handshake after receiving it.
if (
frame.type === 'event' &&
frame.event === 'connect.challenge' &&
!handshakeSent
) {
handshakeSent = true
const connectReq: RequestFrame = {
type: 'req',
id: HANDSHAKE_REQUEST_ID,
method: 'connect',
params: {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: 'openclaw-tui',
displayName: 'browseros-observer',
version: '1.0.0',
platform: 'node',
mode: 'ui',
},
role: 'operator',
scopes: ['operator.read'],
auth: { token: this.gatewayToken },
},
}
ws.send(JSON.stringify(connectReq))
return
}
// Handshake response
if (frame.type === 'res' && frame.id === HANDSHAKE_REQUEST_ID) {
clearTimeout(connectTimeout)
if (frame.ok) {
this.connected = true
logger.info('OpenClaw observer connected')
} else {
logger.warn('OpenClaw observer handshake failed', {
error: frame.error,
})
ws.close()
}
return
}
// Broadcast events (only process after handshake completes)
if (frame.type === 'event' && this.connected) {
this.handleEvent(frame.event, frame.payload)
}
})
ws.on('close', () => {
clearTimeout(connectTimeout)
this.connected = false
this.ws = null
// Reset any agents stuck in "working" to "unknown" — we missed
// the final/end event because the WS closed mid-task. The
// ClawSession will re-infer correct state from JSONL when the
// observer reconnects and ensureObserverConnected() re-seeds.
for (const [agentId, state] of this.session.getAllStates()) {
if (state.status === 'working') {
this.session.transition(agentId, 'unknown')
}
}
if (!this.closed) {
logger.debug('OpenClaw observer disconnected, scheduling reconnect')
this.scheduleReconnect()
}
})
ws.on('error', (err) => {
clearTimeout(connectTimeout)
logger.debug('OpenClaw observer WS error', {
message: err.message,
})
})
}
private handleEvent(eventName: string, payload: unknown): void {
if (eventName === 'chat') {
this.handleChatEvent(payload)
}
}
/**
* Parse a gateway chat broadcast event and transition the ClawSession
* state machine accordingly.
*/
private handleChatEvent(payload: unknown): void {
if (!payload || typeof payload !== 'object') return
const p = payload as Record<string, unknown>
const sessionKey = typeof p.sessionKey === 'string' ? p.sessionKey : null
const state = typeof p.state === 'string' ? p.state : null
if (!sessionKey || !state) return
const agentId = extractAgentId(sessionKey)
if (!agentId) return
if (state === 'delta' || state === 'streaming') {
this.session.transition(agentId, 'working', {
sessionKey,
currentTool: extractToolName(p),
})
} else if (state === 'final' || state === 'end') {
this.session.transition(agentId, 'idle', { sessionKey })
} else if (state === 'error') {
const errorMsg =
typeof p.errorMessage === 'string'
? p.errorMessage
: typeof p.error === 'string'
? p.error
: 'Unknown error'
this.session.transition(agentId, 'error', { sessionKey, error: errorMsg })
}
}
private scheduleReconnect(): void {
this.clearReconnect()
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.doConnect()
}, RECONNECT_DELAY_MS)
}
private clearReconnect(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Extract agentId from an OpenClaw session key.
* Format: "agent:<agentId>:..." — we take the segment after "agent:".
*/
function extractAgentId(sessionKey: string): string | null {
if (!sessionKey.startsWith('agent:')) return null
const colonIdx = sessionKey.indexOf(':', 6)
if (colonIdx === -1) return sessionKey.slice(6)
return sessionKey.slice(6, colonIdx)
}
/**
* Try to extract a tool name from a chat event payload.
*/
function extractToolName(payload: Record<string, unknown>): string | null {
if (typeof payload.toolName === 'string') return payload.toolName
if (typeof payload.tool === 'string') return payload.tool
const content = payload.content
if (content && typeof content === 'object' && 'name' in content) {
const name = (content as Record<string, unknown>).name
if (typeof name === 'string') return name
}
return null
}

View File

@@ -17,6 +17,7 @@ import {
OPENCLAW_IMAGE,
} from '@browseros/shared/constants/openclaw'
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
import type { AgentStreamEvent } from '../../../lib/agents/types'
import { getOpenClawDir } from '../../../lib/browseros-dir'
import { logger } from '../../../lib/logger'
import { withProcessLock } from '../../../lib/process-lock'
@@ -64,7 +65,6 @@ import {
type OpenClawSessionHistoryEvent,
type OpenClawSessionHistoryMessage,
} from './openclaw-http-client'
import { OpenClawObserver } from './openclaw-observer'
import {
type ResolvedOpenClawProviderConfig,
resolveSupportedOpenClawProvider,
@@ -360,8 +360,6 @@ export class OpenClawService {
private httpClient: OpenClawHttpClient
private openclawDir: string
private hostPort = OPENCLAW_GATEWAY_CONTAINER_PORT
private token: string
private tokenLoaded = false
private lastError: string | null = null
private browserosServerPort: number
private resourcesDir: string | null
@@ -372,7 +370,6 @@ export class OpenClawService {
private stopLogTail: (() => void) | null = null
private lifecycleLock: Promise<void> = Promise.resolve()
private clawSession = new ClawSession()
private observer = new OpenClawObserver(this.clawSession)
constructor(config: OpenClawServiceConfig = {}) {
this.openclawDir = getOpenClawDir()
@@ -381,13 +378,9 @@ export class OpenClawService {
projectDir: this.openclawDir,
browserosRoot: config.browserosDir,
})
this.token = crypto.randomUUID()
this.cliClient = new OpenClawCliClient(this.runtime)
this.bootstrapCliClient = this.buildBootstrapCliClient()
this.httpClient = new OpenClawHttpClient(
this.hostPort,
async () => this.token,
)
this.httpClient = new OpenClawHttpClient(this.hostPort)
this.browserosServerPort =
config.browserosServerPort ?? DEFAULT_PORTS.server
this.resourcesDir = config.resourcesDir ?? null
@@ -423,19 +416,6 @@ export class OpenClawService {
return this.hostPort
}
/**
* Current gateway auth token. The token string is loaded from
* `gateway.auth.token` in the persisted openclaw.json during setup,
* with a freshly generated UUID as fallback. Exposed so the ACPx
* harness can pass it to spawned `openclaw acp` child processes via
* the documented `OPENCLAW_GATEWAY_TOKEN` env var (avoids both the
* `--token` process-listing leak and reliance on a token-file path
* that doesn't exist as a discrete file inside the container).
*/
getGatewayToken(): string {
return this.token
}
/** Subscribe to real-time agent status changes from the ClawSession state machine. */
onAgentStatusChange(
listener: (agentId: string, state: AgentSessionState) => void,
@@ -448,6 +428,70 @@ export class OpenClawService {
return this.clawSession.getState(agentId)
}
/**
* Drive the live-status state machine from a turn lifecycle event the
* AgentHarnessService observed. Replaces the previous WS observer
* pipeline that re-tapped the same gateway events; the harness already
* sees them as ACP `session/update` notifications, so we forward those
* here. Caller passes the stream events verbatim.
*
* `tool_call` and `tool_call_update` populate `currentTool` so the
* dashboard SSE keeps its existing payload shape. `done` clears
* working state to `idle`; `error` keeps a sticky error badge.
*/
recordAgentTurnEvent(
agentId: string,
sessionKey: string,
event:
| { type: 'turn_started' }
| { type: 'turn_event'; event: AgentStreamEvent }
| { type: 'turn_ended'; error?: string },
): void {
if (event.type === 'turn_started') {
this.clawSession.transition(agentId, 'working', { sessionKey })
return
}
if (event.type === 'turn_ended') {
if (event.error !== undefined) {
this.clawSession.transition(agentId, 'error', {
sessionKey,
error: event.error,
})
} else {
this.clawSession.transition(agentId, 'idle', { sessionKey })
}
return
}
const inner = event.event
if (inner.type === 'tool_call') {
this.clawSession.transition(agentId, 'working', {
sessionKey,
currentTool: inner.title ?? null,
})
return
}
if (inner.type === 'error') {
this.clawSession.transition(agentId, 'error', {
sessionKey,
error: inner.message,
})
return
}
if (inner.type === 'done') {
this.clawSession.transition(agentId, 'idle', { sessionKey })
return
}
if (inner.type === 'text_delta') {
// Heartbeat — keep the existing `working` row fresh; preserve
// the last-known currentTool by passing it through.
const prev = this.clawSession.getState(agentId)
this.clawSession.transition(agentId, 'working', {
sessionKey,
currentTool: prev.currentTool,
})
}
}
// ── Lifecycle ────────────────────────────────────────────────────────
/** Warm the VM and gateway image so later setup/start avoids registry work. */
@@ -494,14 +538,13 @@ export class OpenClawService {
providerKeyCount: Object.keys(provider.envValues).length,
})
await this.refreshGatewayAuthToken()
await this.ensureGatewayPortAllocated(logProgress)
logProgress('Bootstrapping OpenClaw config...')
await this.bootstrapCliClient.runOnboard({
acceptRisk: true,
authChoice: 'skip',
gatewayAuth: 'token',
gatewayAuth: 'none',
gatewayBind: 'lan',
gatewayPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
installDaemon: false,
@@ -518,8 +561,6 @@ export class OpenClawService {
logProgress('Validating OpenClaw config...')
await this.assertConfigValid(this.bootstrapCliClient)
await this.refreshGatewayAuthToken()
logProgress('Starting OpenClaw gateway...')
await this.runtime.startGateway(
this.buildGatewayRuntimeSpec(),
@@ -578,8 +619,6 @@ export class OpenClawService {
await this.runtime.ensureReady(logProgress)
logProgress('Refreshing gateway auth token...')
await this.refreshGatewayAuthToken()
await this.ensureStateEnvFile()
await this.ensureGatewayPortAllocated(logProgress)
@@ -633,7 +672,6 @@ export class OpenClawService {
return this.withLifecycleLock('stop', async () => {
logger.info('Stopping OpenClaw service', { hostPort: this.hostPort })
this.controlPlaneStatus = 'disconnected'
this.observer.disconnect()
this.stopGatewayLogTail()
await this.runtime.stopGateway()
logger.info('OpenClaw container stopped')
@@ -650,8 +688,6 @@ export class OpenClawService {
this.controlPlaneStatus = 'reconnecting'
await this.runtime.ensureReady(logProgress)
this.stopGatewayLogTail()
logProgress('Refreshing gateway auth token...')
await this.refreshGatewayAuthToken()
await this.ensureStateEnvFile()
await this.ensureGatewayPortAllocated(logProgress)
logProgress('Restarting OpenClaw gateway...')
@@ -696,8 +732,6 @@ export class OpenClawService {
throw new Error('OpenClaw gateway is not ready')
}
logProgress('Reloading gateway auth token...')
await this.refreshGatewayAuthToken()
this.controlPlaneStatus = 'reconnecting'
logProgress('Reconnecting control plane...')
await this.runControlPlaneCall(() => this.cliClient.probe())
@@ -707,7 +741,6 @@ export class OpenClawService {
async shutdown(): Promise<void> {
this.controlPlaneStatus = 'disconnected'
this.observer.disconnect()
this.stopGatewayLogTail()
try {
await this.runtime.stopGateway()
@@ -1117,7 +1150,6 @@ export class OpenClawService {
try {
await this.runtime.ensureReady()
await this.refreshGatewayAuthToken()
await this.ensureStateEnvFile()
const persistedPort = await readPersistedGatewayPort(this.openclawDir)
@@ -1247,10 +1279,7 @@ export class OpenClawService {
private setPort(hostPort: number): void {
if (hostPort === this.hostPort) return
this.hostPort = hostPort
this.httpClient = new OpenClawHttpClient(
this.hostPort,
async () => this.token,
)
this.httpClient = new OpenClawHttpClient(this.hostPort)
}
private async ensureGatewayPortAllocated(
@@ -1283,25 +1312,13 @@ export class OpenClawService {
}
private async isGatewayAuthenticated(hostPort: number): Promise<boolean> {
if (!this.tokenLoaded) {
logger.debug(
'OpenClaw gateway port is ready before auth token is loaded',
{
hostPort,
},
)
return false
}
const client =
hostPort === this.hostPort
? this.httpClient
: new OpenClawHttpClient(hostPort, async () => this.token)
: new OpenClawHttpClient(hostPort)
const authenticated = await client.isAuthenticated()
if (!authenticated) {
logger.warn('OpenClaw gateway port rejected current auth token', {
hostPort,
})
logger.warn('OpenClaw gateway readiness probe failed', { hostPort })
}
return authenticated
}
@@ -1342,12 +1359,10 @@ export class OpenClawService {
private async runControlPlaneCall<T>(fn: () => Promise<T>): Promise<T> {
try {
await this.ensureTokenLoaded()
const result = await fn()
this.controlPlaneStatus = 'connected'
this.lastGatewayError = null
this.lastRecoveryReason = null
this.ensureObserverConnected()
return result
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
@@ -1359,20 +1374,10 @@ export class OpenClawService {
}
}
private ensureObserverConnected(): void {
if (this.observer.isConnected()) return
// ClawSession starts empty after the JSONL seed was removed; the WS
// observer fills in agent status as events arrive.
const url = `http://127.0.0.1:${this.hostPort}`
this.observer.connect(url, this.token)
}
private classifyControlPlaneError(
error: unknown,
): OpenClawGatewayRecoveryReason {
const message = error instanceof Error ? error.message : String(error)
if (message.includes('Unauthorized')) return 'token_mismatch'
if (message.includes('token')) return 'token_mismatch'
if (message.includes('not ready')) return 'container_not_ready'
return 'unknown'
}
@@ -1600,7 +1605,6 @@ export class OpenClawService {
hostPort: this.hostPort,
hostHome: this.openclawDir,
envFilePath: this.getStateEnvPath(),
gatewayToken: this.tokenLoaded ? this.token : undefined,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}
}
@@ -1705,50 +1709,6 @@ export class OpenClawService {
return true
}
private async ensureTokenLoaded(): Promise<void> {
if (this.tokenLoaded) {
return
}
if (!existsSync(this.getStateConfigPath())) {
return
}
await this.loadTokenFromConfig()
}
private async refreshGatewayAuthToken(): Promise<void> {
this.tokenLoaded = false
if (!existsSync(this.getStateConfigPath())) {
return
}
await this.loadTokenFromConfig()
}
private async loadTokenFromConfig(): Promise<void> {
try {
const config = JSON.parse(
await readFile(this.getStateConfigPath(), 'utf-8'),
) as {
gateway?: {
auth?: {
token?: unknown
}
}
}
const token = config.gateway?.auth?.token
if (typeof token === 'string' && token) {
this.token = token
this.tokenLoaded = true
logger.info('Loaded OpenClaw gateway token from mounted config')
}
} catch (err) {
logger.warn('Failed to load OpenClaw gateway token from mounted config', {
error: err instanceof Error ? err.message : String(err),
})
}
}
private createProgressLogger(
onLog?: (msg: string) => void,
): (msg: string) => void {

View File

@@ -4,16 +4,11 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { createRuntimeStore } from 'acpx/runtime'
import type { OpenClawGatewayChatClient } from '../../api/services/openclaw/openclaw-gateway-chat-client'
import type { AgentDefinition } from './agent-types'
import { prepareClaudeCodeContext } from './claude-code/prepare'
import { prepareCodexContext } from './codex/prepare'
import {
maybeHandleOpenClawTurn,
prepareOpenClawContext,
} from './openclaw/prepare'
import type { AgentPromptInput, AgentStreamEvent } from './types'
import { prepareHermesContext } from './hermes/prepare'
import { prepareOpenClawContext } from './openclaw/prepare'
export interface PreparedAcpxAgentContext {
cwd: string
@@ -22,6 +17,14 @@ export interface PreparedAcpxAgentContext {
commandEnv: Record<string, string>
commandIdentity: string
useBrowserosMcp: boolean
/**
* Hostname the agent should use to reach the BrowserOS HTTP MCP server.
* Default `127.0.0.1` is correct for host-process adapters (claude, codex,
* Phase A host-mode hermes). Container-spawned adapters override this to
* `host.containers.internal` so the URL injected into ACP newSession's
* mcpServers resolves from inside the container.
*/
browserosMcpHost?: string
openclawSessionKey: string | null
}
@@ -35,29 +38,17 @@ export interface PrepareAcpxAgentContextInput {
message: string
}
export interface AcpxAdapterTurnInput {
prompt: AgentPromptInput
prepared: PreparedAcpxAgentContext
sessionStore: ReturnType<typeof createRuntimeStore>
openclawGatewayChat: OpenClawGatewayChatClient | null
}
export interface AcpxAgentAdapter {
prepare(
input: PrepareAcpxAgentContextInput,
): Promise<PreparedAcpxAgentContext>
maybeHandleTurn?(
input: AcpxAdapterTurnInput,
): Promise<ReadableStream<AgentStreamEvent> | null>
}
const ADAPTERS: Record<AgentDefinition['adapter'], AcpxAgentAdapter> = {
claude: { prepare: prepareClaudeCodeContext },
codex: { prepare: prepareCodexContext },
openclaw: {
prepare: prepareOpenClawContext,
maybeHandleTurn: maybeHandleOpenClawTurn,
},
openclaw: { prepare: prepareOpenClawContext },
hermes: { prepare: prepareHermesContext },
}
export function getAcpxAgentAdapter(

View File

@@ -57,6 +57,7 @@ export async function finishBrowserosManagedContext(input: {
skillNames: string[]
promptPrefix: string
commandEnv: Record<string, string>
browserosMcpHost?: string
}): Promise<PreparedAcpxAgentContext> {
const commandIdentity = stableCommandIdentity(input.commandEnv)
const runtimeSessionKey = deriveRuntimeSessionKey({
@@ -83,6 +84,7 @@ export async function finishBrowserosManagedContext(input: {
commandEnv: input.commandEnv,
commandIdentity,
useBrowserosMcp: true,
browserosMcpHost: input.browserosMcpHost,
openclawSessionKey: null,
}
}

View File

@@ -19,13 +19,9 @@ import {
createAgentRegistry,
createRuntimeStore,
} from 'acpx/runtime'
import type { OpenClawGatewayChatClient } from '../../api/services/openclaw/openclaw-gateway-chat-client'
import { getBrowserosDir } from '../browseros-dir'
import { logger } from '../logger'
import {
getAcpxAgentAdapter,
prepareAcpxAgentContext,
} from './acpx-agent-adapter'
import { prepareAcpxAgentContext } from './acpx-agent-adapter'
import {
resolveAgentRuntimePaths,
wrapCommandWithEnv,
@@ -51,11 +47,10 @@ import type {
* when spawning the openclaw ACP adapter inside the gateway container.
*
* Fields are getters (not snapshot values) so the harness picks up the
* current token and VM/container paths at spawn time.
* current VM/container paths at spawn time. The bundled gateway runs
* with `gateway.auth.mode=none`, so no auth token is plumbed through.
*/
export interface OpenclawGatewayAccessor {
/** Current gateway auth token. Passed to `openclaw acp --token`. */
getGatewayToken(): string
/** Container name e.g. browseros-openclaw-openclaw-gateway-1. */
getContainerName(): string
/** LIMA_HOME directory containing the browseros-vm instance. */
@@ -66,6 +61,23 @@ export interface OpenclawGatewayAccessor {
getVmName(): string
}
/**
* Live-getter access to the Hermes container runtime info. Required
* when spawning the hermes ACP adapter inside its long-running idle
* container; absent only in tests / dev fallback (where the harness
* still falls back to a host-process `hermes acp` spawn).
*/
export interface HermesGatewayAccessor {
/** Container name e.g. browseros-hermes-hermes-agent-1. */
getContainerName(): string
/** LIMA_HOME directory containing the browseros-vm instance. */
getLimaHomeDir(): string
/** Resolved path to the `limactl` binary (bundled or host). */
getLimactlPath(): string
/** VM name registered in LIMA_HOME (e.g. browseros-vm). */
getVmName(): string
}
type AcpxRuntimeOptions = {
cwd?: string
browserosDir?: string
@@ -77,14 +89,11 @@ type AcpxRuntimeOptions = {
*/
openclawGateway?: OpenclawGatewayAccessor
/**
* Optional. When wired, the runtime diverts OpenClaw turns that
* carry image attachments to the gateway's HTTP `/v1/chat/completions`
* endpoint (which accepts OpenAI-style `image_url` parts) instead of
* the ACP bridge — the bridge silently drops image content blocks.
* Without this client, image turns to OpenClaw agents fall through to
* the ACP path and the model never sees the image.
* Required for adapter='hermes' agents in production; absence falls
* back to a host-process `hermes acp` spawn (used by tests / dev
* before the container service is wired).
*/
openclawGatewayChat?: OpenClawGatewayChatClient
hermesGateway?: HermesGatewayAccessor
runtimeFactory?: (options: AcpRuntimeOptions) => AcpxCoreRuntime
}
@@ -95,6 +104,7 @@ interface PreparedRuntimeContext {
agentCommandEnv: Record<string, string>
commandIdentity: string
useBrowserosMcp: boolean
browserosMcpHost?: string
openclawSessionKey: string | null
}
@@ -104,7 +114,7 @@ export class AcpxRuntime implements AgentRuntime {
private readonly stateDir: string
private readonly browserosServerPort: number
private readonly openclawGateway: OpenclawGatewayAccessor | null
private readonly openclawGatewayChat: OpenClawGatewayChatClient | null
private readonly hermesGateway: HermesGatewayAccessor | null
private readonly runtimeFactory: (
options: AcpRuntimeOptions,
) => AcpxCoreRuntime
@@ -121,7 +131,7 @@ export class AcpxRuntime implements AgentRuntime {
this.browserosServerPort =
options.browserosServerPort ?? DEFAULT_PORTS.server
this.openclawGateway = options.openclawGateway ?? null
this.openclawGatewayChat = options.openclawGatewayChat ?? null
this.hermesGateway = options.hermesGateway ?? null
this.sessionStore = createRuntimeStore({ stateDir: this.stateDir })
this.runtimeFactory = options.runtimeFactory ?? createAcpRuntime
}
@@ -199,24 +209,6 @@ export class AcpxRuntime implements AgentRuntime {
imageAttachmentCount: imageAttachments.length,
})
const adapter = getAcpxAgentAdapter(input.agent.adapter)
const adapterStream =
(await adapter.maybeHandleTurn?.({
prompt: input,
prepared: {
cwd: prepared.cwd,
runtimeSessionKey: prepared.runtimeSessionKey,
runPrompt: prepared.runPrompt,
commandEnv: prepared.agentCommandEnv,
commandIdentity: prepared.commandIdentity,
useBrowserosMcp: prepared.useBrowserosMcp,
openclawSessionKey: prepared.openclawSessionKey,
},
sessionStore: this.sessionStore,
openclawGatewayChat: this.openclawGatewayChat,
})) ?? null
if (adapterStream) return adapterStream
const runtime = this.getRuntime({
cwd,
permissionMode: input.permissionMode,
@@ -224,6 +216,7 @@ export class AcpxRuntime implements AgentRuntime {
commandEnv: prepared.agentCommandEnv,
commandIdentity: prepared.commandIdentity,
useBrowserosMcp: prepared.useBrowserosMcp,
browserosMcpHost: prepared.browserosMcpHost,
openclawSessionKey: prepared.openclawSessionKey,
})
@@ -271,6 +264,7 @@ export class AcpxRuntime implements AgentRuntime {
agentCommandEnv: prepared.commandEnv,
commandIdentity: prepared.commandIdentity,
useBrowserosMcp: prepared.useBrowserosMcp,
browserosMcpHost: prepared.browserosMcpHost,
openclawSessionKey: prepared.openclawSessionKey,
}
}
@@ -282,14 +276,17 @@ export class AcpxRuntime implements AgentRuntime {
commandEnv: Record<string, string>
commandIdentity: string
useBrowserosMcp: boolean
browserosMcpHost?: string
openclawSessionKey: string | null
}): AcpxCoreRuntime {
const mcpHost = input.browserosMcpHost ?? '127.0.0.1'
const key = JSON.stringify({
cwd: input.cwd,
permissionMode: input.permissionMode,
nonInteractivePermissions: input.nonInteractivePermissions,
commandIdentity: input.commandIdentity,
useBrowserosMcp: input.useBrowserosMcp,
browserosMcpHost: mcpHost,
openclawSessionKey: input.openclawSessionKey,
})
const existing = this.runtimes.get(key)
@@ -301,10 +298,11 @@ export class AcpxRuntime implements AgentRuntime {
agentRegistry: createBrowserosAgentRegistry({
openclawGateway: this.openclawGateway,
openclawSessionKey: input.openclawSessionKey,
hermesGateway: this.hermesGateway,
commandEnv: input.commandEnv,
}),
mcpServers: input.useBrowserosMcp
? createBrowserosMcpServers(this.browserosServerPort)
? createBrowserosMcpServers(this.browserosServerPort, mcpHost)
: [],
permissionMode: input.permissionMode,
nonInteractivePermissions: input.nonInteractivePermissions,
@@ -316,6 +314,7 @@ export class AcpxRuntime implements AgentRuntime {
permissionMode: input.permissionMode,
nonInteractivePermissions: input.nonInteractivePermissions,
browserosServerPort: this.browserosServerPort,
browserosMcpHost: mcpHost,
commandIdentity: input.commandIdentity,
useBrowserosMcp: input.useBrowserosMcp,
openclawSessionKey: input.openclawSessionKey,
@@ -696,12 +695,13 @@ function createAcpxEventStream(
function createBrowserosMcpServers(
browserosServerPort: number,
host = '127.0.0.1',
): NonNullable<AcpRuntimeOptions['mcpServers']> {
return [
{
type: 'http',
name: 'browseros',
url: `http://127.0.0.1:${browserosServerPort}/mcp`,
url: `http://${host}:${browserosServerPort}/mcp`,
headers: [],
},
]
@@ -710,6 +710,7 @@ function createBrowserosMcpServers(
function createBrowserosAgentRegistry(input: {
openclawGateway: OpenclawGatewayAccessor | null
openclawSessionKey: string | null
hermesGateway: HermesGatewayAccessor | null
commandEnv: Record<string, string>
}): AcpRuntimeOptions['agentRegistry'] {
const registry = createAgentRegistry()
@@ -736,6 +737,19 @@ function createBrowserosAgentRegistry(input: {
)
}
if (lower === 'hermes') {
if (!input.hermesGateway) {
// No container service wired (tests, dev fallback). Spawn the
// host-side `hermes` binary if available. acpx's built-in
// hermes registry resolution is good enough — we just inject
// HERMES_HOME via the env wrapper so per-agent state still
// works. Production wires `hermesGateway` and goes through
// the container path below.
return wrapCommandWithEnv('hermes acp', input.commandEnv)
}
return resolveHermesAcpCommand(input.hermesGateway, input.commandEnv)
}
if (lower === 'claude' || lower === 'codex') {
return wrapCommandWithEnv(registry.resolve(agentName), input.commandEnv)
}
@@ -745,6 +759,66 @@ function createBrowserosAgentRegistry(input: {
}
}
/**
* Builds the command string acpx will spawn for a `hermes` adapter.
* Runs `hermes acp` inside the long-running Hermes container via the
* bundled `limactl shell <vm> -- nerdctl exec -i ...` chain so the
* upstream image's `hermes` binary is reused; BrowserOS does not
* require a host-side hermes install.
*
* HERMES_HOME inside the container points at the per-agent home
* directory (translated from the host-side path by prepareHermesContext)
* so each BrowserOS agent stays isolated.
*
* Stdio: with `limactl shell -- nerdctl exec`, hermes' stdio inside the
* container is a real pipe(2), and bytes flow back through limactl/SSH
* to the harness. The Bun↔Python socketpair workaround that the
* Phase A host-process spawn needed (`bash -c "... | tee /dev/null"`) is
* unnecessary here because the multiple pipe→socket conversions
* naturally absorb the issue.
*/
function resolveHermesAcpCommand(
gateway: HermesGatewayAccessor,
commandEnv: Record<string, string>,
): string {
const limactl = gateway.getLimactlPath()
const vm = gateway.getVmName()
const container = gateway.getContainerName()
const limaHome = gateway.getLimaHomeDir()
// Build `nerdctl exec -i -e <K=V> ... <container> hermes acp`. The
// commandEnv typically carries HERMES_HOME (a /data/... path inside
// the container set by prepareHermesContext); pass each value through
// `-e` so hermes sees them, even though the env itself was set on the
// host process. PYTHONUNBUFFERED is sticky on the container; we
// re-add it here for safety.
const argv = [
'env',
`LIMA_HOME=${limaHome}`,
limactl,
'shell',
'--workdir',
'/',
vm,
'--',
'nerdctl',
'exec',
'-i',
'-e',
'PYTHONUNBUFFERED=1',
]
for (const [key, value] of Object.entries(commandEnv)) {
argv.push('-e', `${key}=${value}`)
}
// The upstream nousresearch/hermes-agent image installs hermes into
// /opt/hermes/.venv/bin (note the leading dot). It's not on PATH for
// arbitrary `nerdctl exec` calls — the image's own entrypoint script
// sets the PATH but we override the entrypoint to keep the container
// idle, so we use the absolute path here.
argv.push(container, '/opt/hermes/.venv/bin/hermes', 'acp')
return argv.join(' ')
}
/**
* Builds the command string acpx will spawn for an `openclaw` adapter.
* Runs `openclaw acp` inside the gateway container via the bundled
@@ -752,8 +826,8 @@ function createBrowserosAgentRegistry(input: {
* already installed alongside the gateway is reused; BrowserOS does
* not require a host-side openclaw install.
*
* Auth: `openclaw acp --url ...` deliberately does not reuse implicit
* env/config credentials, so pass the gateway token explicitly.
* Auth: BrowserOS configures the bundled gateway with `gateway.auth.mode=none`,
* so no gateway token flag is needed for the local ACP bridge.
*
* Banner output: OPENCLAW_HIDE_BANNER and OPENCLAW_SUPPRESS_NOTES
* suppress non-JSON-RPC chatter on stdout that would otherwise corrupt
@@ -763,7 +837,6 @@ function resolveOpenclawAcpCommand(
gateway: OpenclawGatewayAccessor,
sessionKey: string | null,
): string {
const token = gateway.getGatewayToken()
const limactl = gateway.getLimactlPath()
const vm = gateway.getVmName()
const container = gateway.getContainerName()
@@ -812,8 +885,6 @@ function resolveOpenclawAcpCommand(
'acp',
'--url',
gatewayUrlInsideContainer,
'--token',
token,
]
if (bridgeSessionKey) {
argv.push('--session', bridgeSessionKey)

View File

@@ -91,7 +91,7 @@ export class RingBuffer {
/** Frames with seq > fromSeq, plus the terminal frame if not already in the slice. */
slice(fromSeq: number): TurnFrame[] {
const live = this.frames.filter((f) => f.seq > fromSeq)
if (this.terminal && !live.some((f) => f.seq === this.terminal!.seq)) {
if (this.terminal && !live.some((f) => f.seq === this.terminal?.seq)) {
// Terminal might have been evicted by overflow; re-attach it so
// subscribers always see a terminal if one exists.
if (this.terminal.seq > fromSeq) live.push(this.terminal)

View File

@@ -84,6 +84,24 @@ export const AGENT_ADAPTER_CATALOG: AgentAdapterDescriptor[] = [
{ id: 'adaptive', label: 'Adaptive' },
],
},
{
id: 'hermes',
name: 'Hermes',
// 'default' means whatever the user configured via `hermes setup` —
// Hermes' config.yaml is the source of truth for the model. ACP exposes
// session/set_model but we don't surface it in Phase A.
defaultModelId: 'default',
defaultReasoningEffort: 'medium',
modelControl: 'best-effort',
// Empty list signals "no per-session model picker" — like OpenClaw.
// Phase A.5 may dynamically populate from session/new response.
models: [],
reasoningEfforts: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium', recommended: true },
{ id: 'high', label: 'High' },
],
},
]
export function getAgentAdapterDescriptor(
@@ -93,7 +111,12 @@ export function getAgentAdapterDescriptor(
}
export function isAgentAdapter(value: unknown): value is AgentAdapter {
return value === 'claude' || value === 'codex' || value === 'openclaw'
return (
value === 'claude' ||
value === 'codex' ||
value === 'openclaw' ||
value === 'hermes'
)
}
export function resolveDefaultModelId(adapter: AgentAdapter): string {

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export type AgentAdapter = 'claude' | 'codex' | 'openclaw'
export type AgentAdapter = 'claude' | 'codex' | 'openclaw' | 'hermes'
export type AgentPermissionMode = 'approve-all'

View File

@@ -0,0 +1,88 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { mkdir } from 'node:fs/promises'
import { HERMES_CONTAINER_HARNESS_DIR } from '@browseros/shared/constants/hermes'
import {
getHermesAgentHomeHostDir,
getHermesHarnessHostDir,
} from '../../../api/services/hermes/hermes-paths'
import type {
PrepareAcpxAgentContextInput,
PreparedAcpxAgentContext,
} from '../acpx-agent-adapter'
import {
finishBrowserosManagedContext,
prepareBrowserosManagedContext,
} from '../acpx-agent-common'
/**
* Translate a host-side hermes home path to its in-container equivalent.
* The container bind-mounts `<browserosDir>/vm/hermes/harness` (host)
* onto `/data/agents/harness` (container), so paths under the host
* harness root map cleanly to `/data/agents/harness/...` inside.
*
* Returns the original host path when it doesn't sit under the harness
* root — used as a defensive escape hatch (tests that inject a custom
* dir, or future host-process fallback that still goes through this
* prepare step).
*/
function translateHermesHomeToContainerPath(
hostHome: string,
browserosDir: string,
): string {
const harnessHostRoot = getHermesHarnessHostDir(browserosDir)
if (hostHome === harnessHostRoot) return HERMES_CONTAINER_HARNESS_DIR
if (hostHome.startsWith(`${harnessHostRoot}/`)) {
return `${HERMES_CONTAINER_HARNESS_DIR}${hostHome.slice(harnessHostRoot.length)}`
}
return hostHome
}
/**
* Prepares Hermes with a per-agent HERMES_HOME under
* `<browserosDir>/vm/hermes/harness/<id>/home`. The provider config
* (config.yaml + .env) was written into this directory at agent-create
* time by AgentHarnessService.writeHermesPerAgentProvider. There is no
* fallback to a global `~/.hermes/` install — Hermes agents always
* carry their own provider config.
*
* HERMES_HOME inside the container is the container-side path
* (`/data/agents/harness/<id>/home`) so Hermes resolves it correctly
* when the runtime spawns `hermes acp` via `nerdctl exec`.
*/
export async function prepareHermesContext(
input: PrepareAcpxAgentContextInput,
): Promise<PreparedAcpxAgentContext> {
const common = await prepareBrowserosManagedContext(input)
// Hermes-specific home lives under vm/ so it's reachable inside the
// Lima VM; the shared `common.paths.agentHome` (under agents/harness)
// is OUTSIDE the VM mount and would not be visible to nerdctl.
const hermesAgentHome = getHermesAgentHomeHostDir({
browserosDir: input.browserosDir,
agentId: input.agent.id,
})
await mkdir(hermesAgentHome, { recursive: true })
const hermesAgentHomeInContainer = translateHermesHomeToContainerPath(
hermesAgentHome,
input.browserosDir,
)
return finishBrowserosManagedContext({
...common,
commandEnv: {
HERMES_HOME: hermesAgentHomeInContainer,
},
// Hermes runs inside a Lima container; the BrowserOS HTTP MCP server
// lives on the host. `host.containers.internal` resolves to the VM
// gateway (via --add-host on the hermes-agent container) so hermes can
// reach the MCP endpoint that the harness injects via newSession.
browserosMcpHost: 'host.containers.internal',
})
}

View File

@@ -1,219 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { randomUUID } from 'node:crypto'
import type { AcpSessionRecord, createRuntimeStore } from 'acpx/runtime'
import type {
OpenAIChatMessage,
OpenAIContentPart,
} from '../../../api/services/openclaw/openclaw-gateway-chat-client'
import { logger } from '../../logger'
import type { AcpxAdapterTurnInput } from '../acpx-agent-adapter'
import type { AgentStreamEvent } from '../types'
type ImageAttachment = Readonly<{ mediaType: string; data: string }>
export async function maybeHandleOpenClawTurn(
input: AcpxAdapterTurnInput,
): Promise<ReadableStream<AgentStreamEvent> | null> {
const imageAttachments = (input.prompt.attachments ?? []).filter((a) =>
a.mediaType.startsWith('image/'),
)
if (imageAttachments.length === 0 || !input.openclawGatewayChat) {
return null
}
return sendOpenclawViaGateway({
prompt: input.prompt,
sessionStore: input.sessionStore,
openclawGatewayChat: input.openclawGatewayChat,
imageAttachments,
cwd: input.prepared.cwd,
runPrompt: input.prepared.runPrompt,
})
}
/** Handles OpenClaw image turns through the gateway HTTP chat endpoint. */
async function sendOpenclawViaGateway(input: {
prompt: AcpxAdapterTurnInput['prompt']
sessionStore: AcpxAdapterTurnInput['sessionStore']
openclawGatewayChat: NonNullable<AcpxAdapterTurnInput['openclawGatewayChat']>
imageAttachments: ReadonlyArray<ImageAttachment>
cwd: string
runPrompt: string
}): Promise<ReadableStream<AgentStreamEvent>> {
const existingRecord = await input.sessionStore.load(input.prompt.sessionKey)
const priorMessages = existingRecord
? recordToOpenAIMessages(existingRecord)
: []
const userContent: OpenAIContentPart[] = [
{
type: 'text',
text: input.runPrompt,
},
...input.imageAttachments.map(
(a): OpenAIContentPart => ({
type: 'image_url',
image_url: { url: `data:${a.mediaType};base64,${a.data}` },
}),
),
]
const messages: OpenAIChatMessage[] = [
...priorMessages,
{ role: 'user', content: userContent },
]
logger.info('Agent harness gateway image turn dispatched', {
agentId: input.prompt.agent.id,
sessionKey: input.prompt.sessionKey,
cwd: input.cwd,
priorMessageCount: priorMessages.length,
imageAttachmentCount: input.imageAttachments.length,
})
const upstream = await input.openclawGatewayChat.streamTurn({
agentId: input.prompt.agent.id,
sessionKey: input.prompt.sessionKey,
messages,
signal: input.prompt.signal,
})
const sessionStore = input.sessionStore
const sessionKey = input.prompt.sessionKey
const userMessageText = input.prompt.message
const imageAttachments = input.imageAttachments
let accumulated = ''
return new ReadableStream<AgentStreamEvent>({
start: (controller) => {
const reader = upstream.getReader()
const persist = async () => {
if (!existingRecord || !accumulated) return
try {
await persistGatewayTurn(
sessionStore,
sessionKey,
userMessageText,
imageAttachments,
accumulated,
)
} catch (err) {
logger.warn(
'Failed to persist gateway image turn to acpx session record',
{
sessionKey,
error: err instanceof Error ? err.message : String(err),
},
)
}
}
;(async () => {
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (value.type === 'text_delta') accumulated += value.text
controller.enqueue(value)
}
await persist()
controller.close()
} catch (err) {
controller.enqueue({
type: 'error',
message: err instanceof Error ? err.message : String(err),
})
controller.close()
}
})().catch(() => {})
},
cancel: () => {
// Best-effort: cancel propagation to the gateway is tracked separately.
},
})
}
async function persistGatewayTurn(
sessionStore: ReturnType<typeof createRuntimeStore>,
sessionKey: string,
userMessageText: string,
imageAttachments: ReadonlyArray<ImageAttachment>,
assistantText: string,
): Promise<void> {
const record = await sessionStore.load(sessionKey)
if (!record) return
const userContent: AcpxUserContent[] = [
{ Text: userMessageText } as AcpxUserContent,
]
for (const _image of imageAttachments) {
userContent.push({ Image: { source: 'base64' } } as AcpxUserContent)
}
const turnId = randomUUID()
const updated = {
...record,
messages: [
...record.messages,
{ User: { id: `user-${turnId}`, content: userContent } },
{ Agent: { content: [{ Text: assistantText }], tool_results: {} } },
],
lastUsedAt: new Date().toISOString(),
} as AcpSessionRecord
await sessionStore.save(updated)
}
function recordToOpenAIMessages(record: AcpSessionRecord): OpenAIChatMessage[] {
const messages: OpenAIChatMessage[] = []
for (const message of record.messages) {
if (message === 'Resume') continue
if ('User' in message) {
const text = message.User.content
.map(userContentToText)
.filter(Boolean)
.join('\n\n')
.trim()
if (text) messages.push({ role: 'user', content: text })
continue
}
if ('Agent' in message) {
const text = message.Agent.content
.map((part) => ('Text' in part ? part.Text : ''))
.join('')
.trim()
if (text) messages.push({ role: 'assistant', content: text })
}
}
return messages
}
type AcpxSessionMessage = AcpSessionRecord['messages'][number]
type AcpxUserContent = Extract<
Exclude<AcpxSessionMessage, 'Resume'>,
{ User: unknown }
>['User']['content'][number]
function userContentToText(content: AcpxUserContent): string {
if ('Text' in content) return unwrapPromptText(content.Text)
if ('Mention' in content) return content.Mention.content
if ('Image' in content) return content.Image.source ? '[image]' : ''
return ''
}
function unwrapPromptText(raw: string): string {
const runtimeMatch = raw.match(
/^<browseros_acpx_runtime\b[\s\S]*?<\/browseros_acpx_runtime>\n\n<user_request>\n([\s\S]*?)\n<\/user_request>$/,
)
if (runtimeMatch) return decodeBasicEntities(runtimeMatch[1]).trim()
const roleMatch = raw.match(
/^<role>[\s\S]*?<\/role>\n\n<user_request>\n([\s\S]*?)\n<\/user_request>$/,
)
if (roleMatch) return decodeBasicEntities(roleMatch[1]).trim()
return raw.trim()
}
function decodeBasicEntities(value: string): string {
return value
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
}

View File

@@ -14,8 +14,6 @@ import {
resolveAgentRuntimePaths,
} from '../acpx-runtime-context'
export { maybeHandleOpenClawTurn } from './image-turn'
const OPENCLAW_BROWSEROS_ACP_INSTRUCTIONS =
'<role>You are running inside BrowserOS through the OpenClaw ACP adapter. Use your OpenClaw identity, memory, and browser tools.</role>'

View File

@@ -235,6 +235,7 @@ function buildCreateArgs(spec: ContainerSpec): string[] {
args.push('--health-retries', String(spec.health.retries))
}
}
if (spec.entrypoint) args.push('--entrypoint', spec.entrypoint)
args.push(spec.image)
args.push(...(spec.command ?? []))

View File

@@ -4,6 +4,10 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import {
HERMES_AGENT_NAME,
HERMES_IMAGE,
} from '@browseros/shared/constants/hermes'
import {
OPENCLAW_AGENT_NAME,
OPENCLAW_IMAGE,
@@ -35,10 +39,14 @@ export class ImageLoader {
/** Resolve BrowserOS agent names to image refs and ensure the image exists. */
async ensureAgentImageLoaded(name: string, onLog?: LogFn): Promise<string> {
if (name !== OPENCLAW_AGENT_NAME) {
throw new ImageLoadError(name, `no agent image mapping: ${name}`)
if (name === OPENCLAW_AGENT_NAME) {
await this.ensureImageLoaded(OPENCLAW_IMAGE, onLog)
return OPENCLAW_IMAGE
}
await this.ensureImageLoaded(OPENCLAW_IMAGE, onLog)
return OPENCLAW_IMAGE
if (name === HERMES_AGENT_NAME) {
await this.ensureImageLoaded(HERMES_IMAGE, onLog)
return HERMES_IMAGE
}
throw new ImageLoadError(name, `no agent image mapping: ${name}`)
}
}

View File

@@ -35,6 +35,14 @@ export interface ContainerSpec {
mounts?: MountSpec[]
addHosts?: string[]
health?: HealthConfig
/**
* Override the image's ENTRYPOINT. When set, nerdctl is invoked with
* `--entrypoint <value>`; the `command` array is appended as args to
* this entrypoint. Useful for keeping a service-style image alive in
* the background (e.g. `tini -- sh -c "exec sleep infinity"`) so that
* other code paths can `nerdctl exec` into it per turn.
*/
entrypoint?: string
command?: string[]
}

View File

@@ -19,7 +19,7 @@ export const agentDefinitions = sqliteTable(
id: text('id').primaryKey(),
name: text('name').notNull(),
adapter: text('adapter', {
enum: ['claude', 'codex', 'openclaw'],
enum: ['claude', 'codex', 'openclaw', 'hermes'],
}).notNull(),
modelId: text('model_id').notNull(),
reasoningEffort: text('reasoning_effort').notNull(),

View File

@@ -12,6 +12,10 @@ import fs from 'node:fs'
import path from 'node:path'
import { EXIT_CODES } from '@browseros/shared/constants/exit-codes'
import { createHttpServer } from './api/server'
import {
configureHermesContainerService,
getHermesContainerService,
} from './api/services/hermes/hermes-container'
import {
configureOpenClawService,
configureVmRuntime,
@@ -150,6 +154,33 @@ export class Application {
})
}
// Hermes container is also best-effort — same crash isolation
// semantics as OpenClaw above. Image is pulled in the background;
// an idle container is brought up so per-turn `nerdctl exec hermes acp`
// calls from the harness don't pay container-create latency.
try {
const hermesService = configureHermesContainerService({
resourcesDir,
})
void hermesService.prewarm().catch((err) =>
logger.warn('Hermes prewarm failed', {
error: err instanceof Error ? err.message : String(err),
}),
)
void hermesService.start().catch((err) =>
logger.warn('Hermes container start failed', {
error: err instanceof Error ? err.message : String(err),
}),
)
} catch (err) {
logger.warn(
'Hermes container configuration failed, continuing without it',
{
error: err instanceof Error ? err.message : String(err),
},
)
}
metrics.log('http_server.started', { version: VERSION })
}
@@ -159,6 +190,9 @@ export class Application {
getOpenClawService()
.shutdown()
.catch(() => {})
getHermesContainerService()
.shutdown()
.catch(() => {})
removeServerConfigSync()
// Immediate exit without graceful shutdown. Chromium may kill us on update/restart,

View File

@@ -696,7 +696,7 @@ function createFakeService(agents: AgentDefinition[]) {
},
async createAgent(input: {
name: string
adapter: 'claude' | 'codex' | 'openclaw'
adapter: 'claude' | 'codex' | 'openclaw' | 'hermes'
modelId?: string
reasoningEffort?: string
}) {

View File

@@ -4,6 +4,9 @@
*/
import { describe, expect, it } from 'bun:test'
import { mkdtempSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { AgentHarnessService } from '../../../../src/api/services/agents/agent-harness-service'
import type { AgentStore } from '../../../../src/lib/agents/agent-store'
import type { AgentDefinition } from '../../../../src/lib/agents/agent-types'
@@ -440,6 +443,171 @@ describe('AgentHarnessService', () => {
const listed = await service.listAgentsWithActivity()
expect(listed[0]?.status).toBe('error')
})
it('writes a per-agent Hermes config.yaml + .env when adapter=hermes and provider config complete', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
const agent = await service.createAgent({
name: 'Hermes bot',
adapter: 'hermes',
providerType: 'openrouter',
apiKey: 'sk-or-v1-test-key',
modelId: 'anthropic/claude-haiku-4.5',
})
const homeDir = join(
browserosDir,
'vm',
'hermes',
'harness',
agent.id,
'home',
)
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
const env = readFileSync(join(homeDir, '.env'), 'utf8')
expect(yaml).toContain('"openrouter"')
expect(yaml).toContain('"anthropic/claude-haiku-4.5"')
expect(env).toContain('OPENROUTER_API_KEY=sk-or-v1-test-key')
})
it('rejects Hermes agent creation when apiKey is missing', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
await expect(
service.createAgent({
name: 'Hermes bot',
adapter: 'hermes',
providerType: 'openrouter',
modelId: 'anthropic/claude-haiku-4.5',
}),
).rejects.toThrow(/apiKey/i)
expect(agents).toHaveLength(0)
})
it('rejects Hermes agent creation when providerType is missing', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
await expect(
service.createAgent({ name: 'Hermes bot', adapter: 'hermes' }),
).rejects.toThrow(/providerType/i)
expect(agents).toHaveLength(0)
})
it('rejects Hermes agent creation when modelId is missing', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
await expect(
service.createAgent({
name: 'Hermes bot',
adapter: 'hermes',
providerType: 'openrouter',
apiKey: 'sk-or-v1-test-key',
}),
).rejects.toThrow(/modelId/i)
expect(agents).toHaveLength(0)
})
it('writes Hermes per-agent base_url for openai-compatible providers (mapped to Hermes openai key)', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
const agent = await service.createAgent({
name: 'Custom Hermes',
adapter: 'hermes',
providerType: 'openai-compatible',
apiKey: 'sk-test',
modelId: 'my-model',
baseUrl: 'https://api.example.com/v1',
})
const homeDir = join(
browserosDir,
'vm',
'hermes',
'harness',
agent.id,
'home',
)
const yaml = readFileSync(join(homeDir, 'config.yaml'), 'utf8')
const env = readFileSync(join(homeDir, '.env'), 'utf8')
// BrowserOS' openai-compatible type routes through Hermes' `openai`
// provider with base_url set.
expect(yaml).toContain('"openai"')
expect(yaml).toContain('"my-model"')
expect(yaml).toContain('"https://api.example.com/v1"')
expect(env).toContain('OPENAI_API_KEY=sk-test')
})
it('rejects openai-compatible Hermes agent creation when baseUrl is missing', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
await expect(
service.createAgent({
name: 'Custom Hermes',
adapter: 'hermes',
providerType: 'openai-compatible',
apiKey: 'sk-test',
modelId: 'my-model',
}),
).rejects.toThrow(/baseUrl/i)
expect(agents).toHaveLength(0)
})
it('rejects Hermes agent creation when providerType is not in the supported set', async () => {
const agents: AgentDefinition[] = []
const browserosDir = mkdtempSync(join(tmpdir(), 'browseros-hermes-test-'))
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
browserosDir,
})
await expect(
service.createAgent({
name: 'Unknown Hermes',
adapter: 'hermes',
providerType: 'bedrock',
apiKey: 'sk-test',
modelId: 'm',
}),
).rejects.toThrow(/not supported/i)
expect(agents).toHaveLength(0)
})
})
function stubRuntime(): AgentRuntime {

View File

@@ -159,6 +159,31 @@ describe('ContainerRuntime', () => {
)
})
it('passes private-ingress no-auth only when requested', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.startGateway({
...defaultSpec,
gatewayToken: undefined,
privateIngressNoAuth: true,
})
expect(deps.shell.createContainer).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({
OPENCLAW_GATEWAY_PRIVATE_INGRESS_NO_AUTH: '1',
}),
}),
undefined,
)
})
it('delegates ensureReady and stopVm to VmRuntime', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({

View File

@@ -31,21 +31,52 @@ describe('cleanHistoryUserText', () => {
)
})
// Mirrors `BROWSEROS_ACP_AGENT_INSTRUCTIONS` from acpx-runtime.ts.
// `unwrapBrowserosAcpUserMessage`'s `stripOuterRoleEnvelope` performs
// an exact-prefix/suffix match against this constant, so test fixtures
// need the full text — not a truncated stand-in.
const ROLE_BLOCK =
'<role>\n' +
'You are BrowserOS - a browser agent with full control of a Chromium browser through the BrowserOS MCP server.\n\n' +
'Use the BrowserOS MCP server for all browser tasks, including browsing the web, interacting with pages, inspecting browser state, and managing tabs, windows, bookmarks, and history.\n' +
'</role>'
it('unwraps the BrowserOS ACP user_request envelope', () => {
const raw =
'[Working directory: /tmp/workspace]\n\n' +
'<role>\nYou are BrowserOS - a browser agent...\n</role>\n\n' +
'<user_request>\nhey\n</user_request>'
const raw = `${ROLE_BLOCK}\n\n<user_request>\nhey\n</user_request>`
expect(cleanHistoryUserText(raw)).toBe('hey')
})
it('strips a trailing system-reminder block', () => {
it("strips OpenClaw acp-cli's leading [Working directory:] line", () => {
// OpenClaw 2026.5.x's acp-cli prepends `[Working directory: <path>]
// \n\n` before the BrowserOS envelope. We strip that line up-front
// so the inner `<role>…</role>\n\n<user_request>` envelope can be
// unwrapped by `unwrapBrowserosAcpUserMessage`.
const raw =
'[Working directory: /tmp/workspace]\n\n' +
'<role>\nYou are BrowserOS\n</role>\n\n' +
'<user_request>\nopen google.com\n</user_request>\n\n' +
'<system-reminder>\nA reminder the user never typed.\n</system-reminder>'
expect(cleanHistoryUserText(raw)).toBe('open google.com')
'[Working directory: /Users/me/.browseros-dev/agents/harness/workspace]\n\n' +
`${ROLE_BLOCK}\n\n<user_request>\nhey\n</user_request>`
expect(cleanHistoryUserText(raw)).toBe('hey')
})
it('strips the full OpenClaw acp-cli envelope on image-attachment turns', () => {
// OpenClaw 2026.5.4+ (post image-bypass deletion) wraps user
// messages with stacked envelope lines:
// [media attached: <path> (<mime>)]
// [<weekday> <date> <tz>] [Working directory: <path>]
// <BrowserOS role envelope>
const raw =
'[media attached: /home/node/.openclaw/media/inbound/image---abc.png (image/png)]\n' +
'[Thu 2026-05-07 02:07 GMT+5:30] [Working directory: /Users/me/.browseros-dev/agents/harness/workspace]\n\n' +
`${ROLE_BLOCK}\n\n<user_request>\nWhat color is this?\n</user_request>`
expect(cleanHistoryUserText(raw)).toBe('What color is this?')
})
it('strips multiple stacked [media attached:] lines (one per attachment)', () => {
const raw =
'[media attached: /home/node/.openclaw/media/inbound/image---a.png (image/png)]\n' +
'[media attached: /home/node/.openclaw/media/inbound/image---b.jpg (image/jpeg)]\n' +
'[Thu 2026-05-07 02:07 GMT+5:30] [Working directory: /workspace]\n\n' +
`${ROLE_BLOCK}\n\n<user_request>\nCompare these.\n</user_request>`
expect(cleanHistoryUserText(raw)).toBe('Compare these.')
})
it('splits queued-marker concatenations and cleans each chunk', () => {

View File

@@ -14,10 +14,10 @@ describe('OpenClawHttpClient', () => {
globalThis.fetch = originalFetch
})
it('checks gateway authentication with the current bearer token', async () => {
it('checks no-auth gateway availability without an Authorization header', async () => {
const fetchMock = mock(() => Promise.resolve(new Response('{}')))
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const client = new OpenClawHttpClient(18789)
await expect(client.isAuthenticated()).resolves.toBe(true)
@@ -26,17 +26,15 @@ describe('OpenClawHttpClient', () => {
)
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'GET',
headers: {
Authorization: 'Bearer gateway-token',
},
})
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
})
it('treats rejected gateway authentication as unavailable', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response('Unauthorized', { status: 401 })),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const client = new OpenClawHttpClient(18789)
await expect(client.isAuthenticated()).resolves.toBe(false)
})
@@ -45,13 +43,13 @@ describe('OpenClawHttpClient', () => {
globalThis.fetch = mock(() =>
Promise.reject(new Error('connect ECONNREFUSED')),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const client = new OpenClawHttpClient(18789)
await expect(client.isAuthenticated()).resolves.toBe(false)
})
describe('getSessionHistory', () => {
it('sends GET with bearer auth and forwards limit/cursor as query params', async () => {
it('sends GET and forwards limit/cursor as query params', async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(
@@ -69,7 +67,7 @@ describe('OpenClawHttpClient', () => {
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const client = new OpenClawHttpClient(18789)
const result = await client.getSessionHistory('agent:main:main', {
limit: 50,
@@ -79,10 +77,8 @@ describe('OpenClawHttpClient', () => {
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'http://127.0.0.1:18789/sessions/agent%3Amain%3Amain/history?limit=50&cursor=abc',
)
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'GET',
headers: { Authorization: 'Bearer gateway-token' },
})
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ method: 'GET' })
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
expect(result).toEqual({
sessionKey: 'agent:main:main',
messages: [
@@ -94,6 +90,25 @@ describe('OpenClawHttpClient', () => {
})
})
it('sends no Authorization header when no token provider is configured', async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ sessionKey: 'k', messages: [] }), {
status: 200,
}),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789)
await client.getSessionHistory('k')
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'GET',
})
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
})
it('omits limit and cursor from the query when undefined', async () => {
const fetchMock = mock(() =>
Promise.resolve(
@@ -103,7 +118,7 @@ describe('OpenClawHttpClient', () => {
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const client = new OpenClawHttpClient(18789)
await client.getSessionHistory('k')
@@ -116,7 +131,7 @@ describe('OpenClawHttpClient', () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response('not found', { status: 404 })),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const client = new OpenClawHttpClient(18789)
await expect(
client.getSessionHistory('missing-key'),
@@ -127,7 +142,7 @@ describe('OpenClawHttpClient', () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response('boom', { status: 500 })),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const client = new OpenClawHttpClient(18789)
await expect(client.getSessionHistory('k')).rejects.toThrow('boom')
})
@@ -142,7 +157,7 @@ describe('OpenClawHttpClient', () => {
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const controller = new AbortController()
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const client = new OpenClawHttpClient(18789)
await client.getSessionHistory('k', { signal: controller.signal })
@@ -179,7 +194,7 @@ describe('OpenClawHttpClient', () => {
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const client = new OpenClawHttpClient(18789)
const stream = await client.streamSessionHistory('k', { limit: 20 })
@@ -189,11 +204,9 @@ describe('OpenClawHttpClient', () => {
)
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'GET',
headers: {
Accept: 'text/event-stream',
Authorization: 'Bearer gateway-token',
},
headers: { Accept: 'text/event-stream' },
})
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
expect(events).toEqual([
{
type: 'history',
@@ -215,6 +228,33 @@ describe('OpenClawHttpClient', () => {
])
})
it('keeps SSE Accept without Authorization when no token provider is configured', async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
controller.close()
},
}),
{ status: 200 },
),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789)
await client.streamSessionHistory('k')
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'GET',
headers: {
Accept: 'text/event-stream',
},
})
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
})
it('forwards upstream error frames and closes', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
@@ -234,7 +274,7 @@ describe('OpenClawHttpClient', () => {
),
),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const client = new OpenClawHttpClient(18789)
const stream = await client.streamSessionHistory('k')
@@ -247,7 +287,7 @@ describe('OpenClawHttpClient', () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response('not found', { status: 404 })),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const client = new OpenClawHttpClient(18789)
await expect(client.streamSessionHistory('k')).rejects.toBeInstanceOf(
OpenClawSessionNotFoundError,
@@ -284,7 +324,7 @@ describe('OpenClawHttpClient', () => {
),
),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const client = new OpenClawHttpClient(18789)
const stream = await client.streamSessionHistory('k', {
signal: ac.signal,
@@ -315,3 +355,10 @@ async function readEvents(
return events
}
function fetchHeaders(
fetchMock: ReturnType<typeof mock>,
): Record<string, string> {
return ((fetchMock.mock.calls[0]?.[1] as RequestInit | undefined)?.headers ??
{}) as Record<string, string>
}

View File

@@ -338,7 +338,7 @@ describe('OpenClawService', () => {
expect(runOnboard).toHaveBeenCalledWith({
acceptRisk: true,
authChoice: 'skip',
gatewayAuth: 'token',
gatewayAuth: 'none',
gatewayBind: 'lan',
gatewayPort: 18789,
installDaemon: false,
@@ -377,7 +377,6 @@ describe('OpenClawService', () => {
hostPort: expect.any(Number),
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
gatewayToken: undefined,
}),
expect.any(Function),
)
@@ -434,66 +433,6 @@ describe('OpenClawService', () => {
expect(restartGateway).not.toHaveBeenCalled()
})
it('loads the persisted gateway token from the mounted config before control plane calls', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
await writeFile(
join(tempDir, '.openclaw', 'openclaw.json'),
JSON.stringify({
gateway: {
auth: {
token: 'cli-token',
},
},
}),
)
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.token = 'random-token'
service.runtime = {
isReady: async () => true,
}
service.cliClient = {
listAgents: mock(async () => {
expect(service.token).toBe('cli-token')
return []
}),
}
await service.listAgents()
})
it('caches the loaded gateway token from config across steady-state control plane calls', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
await writeFile(
join(tempDir, '.openclaw', 'openclaw.json'),
JSON.stringify({
gateway: {
auth: {
token: 'cli-token',
},
},
}),
)
const listAgents = mock(async () => [])
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.runtime = {
isReady: async () => true,
}
service.cliClient = {
listAgents,
}
await service.listAgents()
await service.listAgents()
expect(listAgents).toHaveBeenCalledTimes(2)
})
it('writes provider credentials into the mounted state env file during setup', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
const service = new OpenClawService() as MutableOpenClawService
@@ -672,7 +611,6 @@ describe('OpenClawService', () => {
hostPort: expect.any(Number),
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
gatewayToken: 'cli-token',
}),
expect.any(Function),
)
@@ -887,7 +825,6 @@ describe('OpenClawService', () => {
hostPort: expect.any(Number),
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
gatewayToken: 'cli-token',
}),
expect.any(Function),
)
@@ -1096,7 +1033,6 @@ describe('OpenClawService', () => {
hostPort: expect.any(Number),
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
gatewayToken: 'cli-token',
}),
)
expect(waitForReady).toHaveBeenCalledTimes(1)
@@ -1136,6 +1072,53 @@ describe('OpenClawService', () => {
expect(probe).toHaveBeenCalledTimes(1)
})
it('tryAutoStart reuses a ready no-auth gateway without Authorization', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
await writeFile(
join(tempDir, '.openclaw', 'openclaw.json'),
JSON.stringify({
gateway: {
auth: {
mode: 'none',
token: 'stale-token',
},
},
}),
)
const ensureReady = mock(async () => {})
const isReady = mock(async () => true)
const isGatewayCurrent = mock(async () => true)
const startGateway = mock(async () => {})
const probe = mock(async () => {})
const fetchMock = mock(() =>
Promise.resolve(new Response('', { status: 200 })),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const service = new OpenClawService() as MutableOpenClawService
service.openclawDir = tempDir
service.runtime = {
ensureReady,
isReady,
isGatewayCurrent,
startGateway,
}
service.cliClient = { probe }
await service.tryAutoStart()
expect(startGateway).not.toHaveBeenCalled()
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'http://127.0.0.1:18789/v1/models',
)
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'GET',
})
expect(fetchHeaders(fetchMock)).not.toHaveProperty('Authorization')
expect(probe).toHaveBeenCalledTimes(1)
})
it('tryAutoStart recreates a ready gateway when the image is stale', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
@@ -1720,3 +1703,10 @@ function mockGatewayAuth(status = 200): ReturnType<typeof mock> {
globalThis.fetch = fetchMock as typeof globalThis.fetch
return fetchMock
}
function fetchHeaders(
fetchMock: ReturnType<typeof mock>,
): Record<string, string> {
return ((fetchMock.mock.calls[0]?.[1] as RequestInit | undefined)?.headers ??
{}) as Record<string, string>
}

View File

@@ -110,4 +110,33 @@ describe('prepareAcpxAgentContext', () => {
expect(prepared.runPrompt).not.toContain('AGENT_HOME/MEMORY.md')
expect(prepared.runPrompt).not.toContain('Available skills:')
})
it('prepares Hermes with HERMES_HOME pointing at the in-container agent home (translated from the host path)', async () => {
const browserosDir = await mkdtemp(join(tmpdir(), 'browseros-adapters-'))
tempDirs.push(browserosDir)
const prepared = await prepareAcpxAgentContext({
browserosDir,
agent: makeAgent('hermes'),
sessionId: 'main',
sessionKey: 'agent:hermes-agent:main',
cwdOverride: null,
isSelectedCwd: false,
message: 'remember this',
})
// HERMES_HOME must be the *container-side* path (under /data) so the
// hermes binary running inside the container can actually open it.
// The host-side seeded files are reachable via the bind mount.
expect(prepared.commandEnv.HERMES_HOME).toBe(
'/data/agents/harness/hermes-agent/home',
)
expect(prepared.commandEnv).not.toHaveProperty('AGENT_HOME')
expect(prepared.commandEnv).not.toHaveProperty('CODEX_HOME')
expect(prepared.commandEnv).not.toHaveProperty('CLAUDE_CONFIG_DIR')
expect(prepared.useBrowserosMcp).toBe(true)
expect(prepared.openclawSessionKey).toBeNull()
expect(prepared.runtimeSessionKey).toMatch(
/^agent:hermes-agent:main:[a-f0-9]{16}$/,
)
})
})

View File

@@ -955,6 +955,99 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
expect(command).toContain('/runtime/codex-home')
})
it('resolves the Hermes adapter to a container `nerdctl exec hermes acp` command when a HermesGatewayAccessor is wired', async () => {
const browserosDir = await mkdtemp(
join(tmpdir(), 'browseros-acpx-browseros-'),
)
const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-'))
tempDirs.push(browserosDir, stateDir)
const calls: Array<{ method: string; input: unknown }> = []
const runtime = new AcpxRuntime({
browserosDir,
stateDir,
hermesGateway: {
getContainerName: () => 'browseros-hermes-hermes-agent-1',
getLimaHomeDir: () => '/Users/dev/.browseros-dev/lima',
getLimactlPath: () => '/opt/homebrew/bin/limactl',
getVmName: () => 'browseros-vm',
},
runtimeFactory: (options) => {
calls.push({ method: 'createRuntime', input: options })
return createFakeAcpRuntime(calls)
},
})
const agent = makeAgent({ id: 'agent-1', adapter: 'hermes' })
await collectStream(
await runtime.send({
agent,
sessionId: 'main',
sessionKey: agent.sessionKey,
message: 'hi',
permissionMode: 'approve-all',
}),
)
const command =
getCreateRuntimeOptions(calls).agentRegistry.resolve('hermes')
// Container-spawn path uses limactl shell + nerdctl exec; no host-
// process bash/tee workaround (those were Phase A only).
expect(command).toContain('env LIMA_HOME=/Users/dev/.browseros-dev/lima')
expect(command).toContain(
'/opt/homebrew/bin/limactl shell --workdir / browseros-vm --',
)
expect(command).toContain('nerdctl exec -i')
expect(command).toContain('browseros-hermes-hermes-agent-1')
expect(command).toContain('hermes acp')
expect(command).toContain('HERMES_HOME=')
expect(command).not.toContain('bash -c')
expect(command).not.toContain('tee /dev/null')
expect(command).not.toContain('AGENT_HOME=')
expect(command).not.toContain('CODEX_HOME=')
expect(command).not.toContain('CLAUDE_CONFIG_DIR=')
})
it('falls back to a host-process `hermes acp` command when no HermesGatewayAccessor is wired', async () => {
const browserosDir = await mkdtemp(
join(tmpdir(), 'browseros-acpx-browseros-'),
)
const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-'))
tempDirs.push(browserosDir, stateDir)
const calls: Array<{ method: string; input: unknown }> = []
const runtime = new AcpxRuntime({
browserosDir,
stateDir,
runtimeFactory: (options) => {
calls.push({ method: 'createRuntime', input: options })
return createFakeAcpRuntime(calls)
},
})
const agent = makeAgent({ id: 'agent-1', adapter: 'hermes' })
await collectStream(
await runtime.send({
agent,
sessionId: 'main',
sessionKey: agent.sessionKey,
message: 'hi',
permissionMode: 'approve-all',
}),
)
const command =
getCreateRuntimeOptions(calls).agentRegistry.resolve('hermes')
// Host-process fallback: bare `hermes acp` with HERMES_HOME injected
// via wrapCommandWithEnv. No limactl/nerdctl chain — used by tests
// and as a defensive escape hatch when the container service hasn't
// been wired yet.
expect(command).toContain('hermes acp')
expect(command).toContain('env HERMES_HOME=')
expect(command).not.toContain('limactl')
expect(command).not.toContain('nerdctl')
expect(command).not.toContain('bash -c')
expect(command).not.toContain('tee /dev/null')
})
it('does not reuse an Acpx runtime across different command identities', async () => {
const browserosDir = await mkdtemp(
join(tmpdir(), 'browseros-acpx-browseros-'),
@@ -1043,9 +1136,8 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
expect(command).toContain(
'nerdctl exec -i -e OPENCLAW_HIDE_BANNER=1 -e OPENCLAW_SUPPRESS_NOTES=1 browseros-openclaw-openclaw-gateway-1',
)
expect(command).toContain(
'openclaw acp --url ws://127.0.0.1:18789 --token test-token-abc',
)
expect(command).toContain('openclaw acp --url ws://127.0.0.1:18789')
expect(command).not.toContain('--token')
// sessionKey routing: the bridge needs --session <key> to map newSession
// requests to the matching gateway agent (acpx does not forward
// sessionKey via ACP newSession params).
@@ -1253,142 +1345,6 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
.map((call) => (call.input as { timeoutMs?: number }).timeoutMs),
).toEqual([1_000, 2_000])
})
it('diverts OpenClaw image turns to the gateway chat client and persists them to the session record', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'browseros-acpx-runtime-'))
const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-'))
tempDirs.push(cwd, stateDir)
// Pre-seed the session record so persistence has somewhere to land.
// (First-turn-image-only sessions deliberately skip persistence; that
// path is covered by the empty-record test below.)
const sessionStore = createRuntimeStore({ stateDir })
const seedTimestamp = '2026-04-28T20:00:00.000Z'
const seedRecord: AcpSessionRecord = {
schema: 'acpx.session.v1',
acpxRecordId: 'agent:img-bot:main',
acpSessionId: 'sid-img',
agentSessionId: 'inner-img',
agentCommand: 'env LIMA_HOME=/tmp limactl shell vm -- nerdctl exec',
cwd,
name: 'agent:img-bot:main',
createdAt: seedTimestamp,
lastUsedAt: seedTimestamp,
lastSeq: 0,
eventLog: {
active_path: '',
segment_count: 0,
max_segment_bytes: 0,
max_segments: 0,
},
closed: false,
messages: [
{
User: {
id: 'prior-user',
content: [{ Text: 'literal &amp; &lt;tag&gt;' } as never],
},
},
{ Agent: { content: [{ Text: 'Prior answer.' }], tool_results: {} } },
],
updated_at: seedTimestamp,
cumulative_token_usage: {},
request_token_usage: {},
acpx: {},
}
await sessionStore.save(seedRecord)
const gatewayCalls: Array<{ method: string; input: unknown }> = []
const openclawGatewayChat = {
streamTurn: async (input: unknown) => {
gatewayCalls.push({ method: 'streamTurn', input })
return new ReadableStream<AgentStreamEvent>({
start(controller) {
controller.enqueue({
type: 'text_delta',
text: 'Red.',
stream: 'output',
})
controller.enqueue({ type: 'done', stopReason: 'end_turn' })
controller.close()
},
})
},
} as never
const calls: Array<{ method: string; input: unknown }> = []
const runtime = new AcpxRuntime({
cwd,
stateDir,
openclawGatewayChat,
// Provide a runtime factory that would fail loudly if reached —
// image turns must NOT fall through to the ACP path.
runtimeFactory: (options) => {
calls.push({ method: 'createRuntime', input: options })
throw new Error('ACP path should not be reached for image turns')
},
})
const agent: AgentDefinition = {
id: 'img-bot',
name: 'OpenClaw image bot',
adapter: 'openclaw',
permissionMode: 'approve-all',
sessionKey: 'agent:img-bot:main',
createdAt: 1000,
updatedAt: 1000,
}
const events = await collectStream(
await runtime.send({
agent,
sessionId: 'main',
sessionKey: agent.sessionKey,
message: 'What color is this?',
attachments: [{ mediaType: 'image/png', data: 'BASE64DATA' }],
permissionMode: 'approve-all',
}),
)
expect(events).toEqual([
{ type: 'text_delta', text: 'Red.', stream: 'output' },
{ type: 'done', stopReason: 'end_turn' },
])
expect(gatewayCalls).toHaveLength(1)
expect(
calls.filter((call) => call.method === 'createRuntime'),
).toHaveLength(0)
const gatewayInput = gatewayCalls[0]?.input as {
agentId: string
sessionKey: string
messages: Array<{
role: string
content: string | Array<{ type: string }>
}>
}
expect(gatewayInput.agentId).toBe('img-bot')
expect(gatewayInput.messages[0]).toEqual({
role: 'user',
content: 'literal &amp; &lt;tag&gt;',
})
expect(gatewayInput.messages.at(-1)?.role).toBe('user')
const userContent = gatewayInput.messages.at(-1)?.content
expect(Array.isArray(userContent)).toBe(true)
expect(
(userContent as Array<{ type: string }>).filter(
(p) => p.type === 'image_url',
),
).toHaveLength(1)
// Persistence check: history should now show the user+assistant turn.
const history = await runtime.getHistory({
agent,
sessionId: 'main',
})
expect(history.items.slice(-2).map((item) => item.role)).toEqual([
'user',
'assistant',
])
expect(history.items.at(-1)?.text).toBe('Red.')
})
})
function makeAgent(input: {

View File

@@ -12,11 +12,12 @@ import {
} from '../../../src/lib/agents/agent-catalog'
describe('AGENT_ADAPTER_CATALOG', () => {
it('exposes Claude, Codex, and OpenClaw adapters with model and effort options', () => {
it('exposes Claude, Codex, OpenClaw, and Hermes adapters with model and effort options', () => {
expect(AGENT_ADAPTER_CATALOG.map((adapter) => adapter.id)).toEqual([
'claude',
'codex',
'openclaw',
'hermes',
])
expect(getAgentAdapterDescriptor('claude')).toMatchObject({

View File

@@ -121,6 +121,9 @@ async function setupApplicationTest() {
const openclawService = await import(
'../src/api/services/openclaw/openclaw-service'
)
const hermesContainerService = await import(
'../src/api/services/hermes/hermes-container'
)
const browserosDir = await import('../src/lib/browseros-dir')
const dbModule = await import('../src/lib/db')
const identityModule = await import('../src/lib/identity')
@@ -206,6 +209,27 @@ async function setupApplicationTest() {
}) as never,
)
const hermesPrewarm = mock(async () => {})
const hermesStart = mock(async () => {})
spyOn(
hermesContainerService,
'configureHermesContainerService',
).mockImplementation(
() =>
({
prewarm: hermesPrewarm,
start: hermesStart,
}) as never,
)
spyOn(hermesContainerService, 'getHermesContainerService').mockImplementation(
() =>
({
prewarm: hermesPrewarm,
start: hermesStart,
shutdown: async () => {},
}) as never,
)
const { Application } = await import('../src/main')
return {
Application,
@@ -217,5 +241,6 @@ async function setupApplicationTest() {
loggerWarn,
initializeDb,
openClawService: { prewarm, tryAutoStart },
hermesService: { prewarm: hermesPrewarm, start: hermesStart },
}
}

View File

@@ -37,6 +37,10 @@
"types": "./src/constants/openclaw.ts",
"default": "./src/constants/openclaw.ts"
},
"./constants/hermes": {
"types": "./src/constants/hermes.ts",
"default": "./src/constants/hermes.ts"
},
"./constants/tool-approval": {
"types": "./src/constants/tool-approval.ts",
"default": "./src/constants/tool-approval.ts"

View File

@@ -0,0 +1,31 @@
export const HERMES_AGENT_NAME = 'hermes'
export const HERMES_IMAGE = 'docker.io/nousresearch/hermes-agent:v2026.4.30'
export const HERMES_COMPOSE_PROJECT_NAME = 'browseros-hermes'
export const HERMES_CONTAINER_NAME = `${HERMES_COMPOSE_PROJECT_NAME}-hermes-agent-1`
// Inside the container, /data is the volume mount where per-agent HERMES_HOME
// directories live: /data/agents/harness/<agentId>/home. The host-side
// directory that backs this mount lives under the BrowserOS-managed VM
// state directory (so it's reachable inside the Lima VM via the existing
// vm/ mount); the container sees the same files via /data/agents/harness.
export const HERMES_CONTAINER_DATA_DIR = '/data'
export const HERMES_CONTAINER_HARNESS_DIR = `${HERMES_CONTAINER_DATA_DIR}/agents/harness`
/**
* BrowserOS LLM provider types Hermes can consume. The frontend filters
* the global provider list to these; the backend `hermes-provider-map`
* maps them onto Hermes' own provider keys. Keep both sides in sync via
* this single list — adding a new entry without updating the backend
* map will cause a 400 at agent-create time.
*
* Bedrock is intentionally NOT included yet — it needs multiple env
* vars (AWS_ACCESS_KEY_ID + secret + region) and a separate UX path.
*/
export const HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES = [
'anthropic',
'openai',
'openai-compatible',
'openrouter',
] as const
export type HermesSupportedBrowserosProviderType =
(typeof HERMES_SUPPORTED_BROWSEROS_PROVIDER_TYPES)[number]

View File

@@ -1,5 +1,6 @@
export const OPENCLAW_AGENT_NAME = 'openclaw'
export const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:2026.4.12'
export const OPENCLAW_IMAGE =
'ghcr.io/browseros-ai/openclaw:2026.5.4-browseros.1'
export const OPENCLAW_GATEWAY_CONTAINER_PORT = 18789
export const OPENCLAW_CONTAINER_HOME = '/home/node/.openclaw'
export const OPENCLAW_COMPOSE_PROJECT_NAME = 'browseros-openclaw'