Compare commits

..

3 Commits

Author SHA1 Message Date
Nikhil Sonti
1b4aaa75b6 fix(ci): show warning icon for zero-test suites instead of failure 2026-04-15 21:35:36 -07:00
Nikhil Sonti
25320458b9 fix(ci): use payload SHA for staleness check, handle missing artifacts
- Replace context.sha (merge commit SHA) with
  context.payload.pull_request.head.sha so the staleness guard
  compares the correct values and the comment actually gets posted
- Add continue-on-error to download-artifact so cancelled runs
  gracefully fall through to the "no test results" message
2026-04-15 21:05:40 -07:00
Nikhil Sonti
6f9da20fe1 fix(ci): add PR comment with test summary and block on failure
Add a `comment` job to the test workflow that parses JUnit XML artifacts
and posts a sticky PR comment showing pass/fail counts per suite, with
failed test names listed in a collapsible section and a link to the run.

Guards against fork PRs (read-only token) and stale overlapping runs
(skips comment if PR head has moved past our SHA).
2026-04-15 20:54:47 -07:00
171 changed files with 4130 additions and 14740 deletions

View File

@@ -1,157 +0,0 @@
name: build-agent
on:
workflow_dispatch:
inputs:
agent:
description: "Agent name from bundle.json"
required: true
type: string
default: openclaw
publish:
description: "Upload to R2 and merge manifest slice"
required: false
default: false
type: boolean
pull_request:
paths:
- "packages/browseros-agent/packages/build-tools/**"
- ".github/workflows/build-agent.yml"
env:
BUN_VERSION: "1.3.6"
PKG_DIR: packages/browseros-agent/packages/build-tools
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- working-directory: packages/browseros-agent
run: bun run --filter @browseros/build-tools typecheck
- working-directory: packages/browseros-agent
run: bun run --filter @browseros/build-tools test
build:
needs: check
strategy:
fail-fast: false
matrix:
include:
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install podman
run: |
sudo apt-get update
sudo apt-get install -y podman
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- name: Build tarball
working-directory: ${{ env.PKG_DIR }}
env:
AGENT: ${{ inputs.agent || 'openclaw' }}
OUT: ${{ github.workspace }}/dist/images
run: bun run build:tarball -- --agent "$AGENT" --arch "${{ matrix.arch }}" --output-dir "$OUT"
- uses: actions/upload-artifact@v4
with:
name: tarball-${{ inputs.agent || 'openclaw' }}-${{ matrix.arch }}
path: dist/images/
retention-days: 7
smoke:
needs: build
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- uses: actions/download-artifact@v4
with:
name: tarball-${{ inputs.agent || 'openclaw' }}-arm64
path: dist/images
- name: Install podman
run: |
sudo apt-get update
sudo apt-get install -y podman
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- name: Smoke test tarball
working-directory: ${{ env.PKG_DIR }}
env:
AGENT: ${{ inputs.agent || 'openclaw' }}
run: |
set -euo pipefail
tarball="$(find "$GITHUB_WORKSPACE/dist/images" -name "${AGENT}-*-arm64.tar.gz" -print -quit)"
if [ -z "$tarball" ]; then
echo "missing arm64 tarball artifact for ${AGENT}" >&2
exit 1
fi
bun run smoke:tarball -- --agent "$AGENT" --arch arm64 --tarball "$tarball"
publish:
needs: [build, smoke]
if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish == true }}
runs-on: ubuntu-24.04
environment: release
concurrency:
group: r2-manifest-publish
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- uses: actions/download-artifact@v4
with:
pattern: tarball-*
path: dist/images
merge-multiple: true
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- name: Upload tarballs to R2
working-directory: ${{ env.PKG_DIR }}
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
for file in "$GITHUB_WORKSPACE"/dist/images/*.tar.gz; do
base="$(basename "$file")"
bun run upload -- --file "$file" --key "vm/images/$base" --content-type "application/gzip" --sidecar-sha
done
- name: Merge agent slice into manifest
working-directory: ${{ env.PKG_DIR }}
env:
AGENT: ${{ inputs.agent || 'openclaw' }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
mkdir -p dist/images
cp -R "$GITHUB_WORKSPACE"/dist/images/* dist/images/
bun run download -- --key vm/manifest.json --out dist/baseline-manifest.json
bun run emit-manifest -- \
--slice "agents:${AGENT}" \
--dist-dir dist \
--merge-from dist/baseline-manifest.json \
--out dist/manifest.json
bun run upload -- --file dist/manifest.json --key vm/manifest.json --content-type "application/json"

View File

@@ -30,54 +30,12 @@ jobs:
fail-fast: false
matrix:
include:
- suite: server-agent
command: (cd apps/server && bun run test:agent)
junit_path: test-results/server-agent.xml
needs_browser: false
- suite: server-api
command: (cd apps/server && bun run test:api)
junit_path: test-results/server-api.xml
needs_browser: false
- suite: server-skills
command: (cd apps/server && bun run test:skills)
junit_path: test-results/server-skills.xml
needs_browser: false
- suite: server-tools
command: (cd apps/server && bun run test:tools)
junit_path: test-results/server-tools.xml
needs_browser: true
- suite: server-browser
command: (cd apps/server && bun run test:browser)
junit_path: test-results/server-browser.xml
needs_browser: false
- suite: server-integration
command: (cd apps/server && bun run test:integration)
junit_path: test-results/server-integration.xml
needs_browser: true
- suite: server-sdk
command: (cd apps/server && bun run test:sdk)
junit_path: test-results/server-sdk.xml
needs_browser: true
- suite: server-root
command: (cd apps/server && bun run test:root)
junit_path: test-results/server-root.xml
needs_browser: false
- suite: agent
command: bun run test:agent
junit_path: test-results/agent.xml
needs_browser: false
- suite: eval
command: bun run test:eval
junit_path: test-results/eval.xml
needs_browser: false
- suite: agent-sdk
command: bun run test:agent-sdk
junit_path: test-results/agent-sdk.xml
needs_browser: false
- suite: build
command: bun run test:build
junit_path: test-results/build.xml
needs_browser: false
- suite: tools
test_path: tests/tools
junit_path: test-results/tools.xml
- suite: integration
test_path: tests/server.integration.test.ts
junit_path: test-results/integration.xml
steps:
- name: Checkout code
@@ -90,7 +48,6 @@ jobs:
run: bun ci
- name: Resolve BrowserOS cache key
if: matrix.needs_browser == true
id: browseros-cache-key
run: |
set -euo pipefail
@@ -105,7 +62,6 @@ jobs:
echo "key=browseros-appimage-${{ runner.os }}-$cache_key" >> "$GITHUB_OUTPUT"
- name: Restore BrowserOS cache
if: matrix.needs_browser == true
id: browseros-cache
uses: actions/cache@v4
with:
@@ -113,14 +69,13 @@ jobs:
key: ${{ steps.browseros-cache-key.outputs.key }}
- name: Download BrowserOS
if: matrix.needs_browser == true && steps.browseros-cache.outputs.cache-hit != 'true'
if: steps.browseros-cache.outputs.cache-hit != 'true'
run: |
mkdir -p .ci/bin
curl -fsSL "$BROWSEROS_APPIMAGE_URL" -o .ci/bin/BrowserOS.AppImage
chmod +x .ci/bin/BrowserOS.AppImage
- name: Prepare BrowserOS wrapper
if: matrix.needs_browser == true
run: |
mkdir -p .ci/bin
cat > .ci/bin/browseros <<'EOF'
@@ -141,23 +96,16 @@ jobs:
BROWSEROS_BINARY: ${{ github.workspace }}/packages/browseros-agent/.ci/bin/browseros
BROWSEROS_TEST_HEADLESS: "true"
BROWSEROS_TEST_EXTRA_ARGS: --no-sandbox --disable-dev-shm-usage
BROWSEROS_JUNIT_PATH: ${{ github.workspace }}/packages/browseros-agent/${{ matrix.junit_path }}
run: |
set +e
mkdir -p test-results
${{ matrix.command }}
cd apps/server
bun run test:cleanup
bun --env-file=.env.development test "${{ matrix.test_path }}" --reporter=junit --reporter-outfile="../../${{ matrix.junit_path }}"
exit_code=$?
cd ../..
if [ ! -f "${{ matrix.junit_path }}" ]; then
if [ "$exit_code" = "0" ]; then
cat > "${{ matrix.junit_path }}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="0" failures="0">
<testsuite name="${{ matrix.suite }}" tests="0" failures="0">
</testsuite>
</testsuites>
EOF
else
cat > "${{ matrix.junit_path }}" <<EOF
cat > "${{ matrix.junit_path }}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="1" failures="1">
<testsuite name="${{ matrix.suite }}" tests="1" failures="1">
@@ -167,7 +115,6 @@ jobs:
</testsuite>
</testsuites>
EOF
fi
fi
echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"

2
.gitignore vendored
View File

@@ -1,6 +1,4 @@
**/.DS_Store
**.auctor/**
.auctor.json
.gcs_entries
**/dmg
**/env

View File

@@ -14,7 +14,6 @@ lerna-debug.log*
# Ignore all .env files except .env.example
**/.env.*
!**/.env.example
!**/.env.sample
!**/.env.production.example

View File

@@ -218,9 +218,3 @@ This uses the same element resolution as the server's MCP tools — no coordinat
The `<target>` argument can be:
- An **index** from the `targets` output (e.g., `3`)
- A **URL substring** (e.g., `sidepanel`, `newtab`, `chrome-extension://`)
## Release gating — bundled-VM runtime migration (2026-Q2)
Between the Lima server-prod-resources cutover (WS3) and the ContainerRuntime migration (WS6) landing, `resources/bin/third_party/` ships `limactl` instead of `podman`. The current OpenClaw runtime (`apps/server/src/api/services/openclaw/podman-runtime.ts`, `container-runtime.ts`) still invokes `podman`; it will fail to find the binary on builds cut from `dev`.
Do **not** cut a release branch off `dev` during this window. Track WS6 progress before any release cut. See `specs/bundled-vm-runtime-spec.md` + `specs/workstreams.md` for context.

View File

@@ -75,20 +75,26 @@ packages/
### Setup
Requires [process-compose](https://github.com/F1bonacc1/process-compose):
```bash
brew install process-compose
```
```bash
# Copy environment files for each package
cp apps/server/.env.example apps/server/.env.development
cp apps/agent/.env.example apps/agent/.env.development
cp apps/server/.env.production.example apps/server/.env.production
# Install deps, generate agent code, and sync the VM cache
bun run dev:setup
# Start the full dev environment
bun run dev:watch
process-compose up
```
`dev:watch` exits when the VM cache manifest is missing, but setup stays in `dev:setup`.
The `process-compose up` command runs the following in order:
1. `bun install` — installs dependencies
2. `bun --cwd apps/agent codegen` — generates agent code
3. `bun --cwd apps/server start` and `bun --cwd apps/agent dev` — starts server and agent in parallel
### Environment Variables

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import {
buildChatHistoryFromTurns,
chatWithAgent,
type OpenClawStreamEvent,
} from '@/entrypoints/app/agents/useOpenClaw'
@@ -188,7 +187,6 @@ export function useAgentConversation(agentId: string, agentName: string) {
const send = async (text: string) => {
if (!text.trim() || streaming) return
const history = buildChatHistoryFromTurns(turns)
const turn: AgentConversationTurn = {
id: crypto.randomUUID(),
@@ -209,7 +207,6 @@ export function useAgentConversation(agentId: string, agentName: string) {
agentId,
text.trim(),
sessionKeyRef.current,
history,
abortController.signal,
)
if (!response.ok) {

View File

@@ -20,11 +20,7 @@ import {
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { consumeSSEStream } from '@/lib/sse'
import {
buildChatHistoryFromTurns,
chatWithAgent,
type OpenClawStreamEvent,
} from './useOpenClaw'
import { chatWithAgent, type OpenClawStreamEvent } from './useOpenClaw'
interface ToolEntry {
id: string
@@ -208,7 +204,6 @@ export const AgentChat: FC<AgentChatProps> = ({
const handleSend = async () => {
const text = input.trim()
if (!text || streaming) return
const history = buildChatHistoryFromTurns(turns)
const turn: ChatTurn = {
id: crypto.randomUUID(),
@@ -230,7 +225,6 @@ export const AgentChat: FC<AgentChatProps> = ({
agentId,
text,
sessionKeyRef.current,
history,
abortController.signal,
)

View File

@@ -1,3 +1,7 @@
import type {
BrowserOSCustomRoleInput,
BrowserOSRoleBoundary,
} from '@browseros/shared/types/role-aware-agents'
import {
AlertCircle,
Cpu,
@@ -31,25 +35,53 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { AgentChat } from './AgentChat'
import { AgentTerminal } from './AgentTerminal'
import { getOpenClawSupportedProviders } from './openclaw-supported-providers'
import {
type AgentEntry,
type GatewayLifecycleAction,
type OpenClawStatus,
type RoleTemplateSummary,
useOpenClawAgents,
useOpenClawMutations,
useOpenClawRoles,
useOpenClawStatus,
} from './useOpenClaw'
const LIFECYCLE_BANNER_COPY: Record<GatewayLifecycleAction, string> = {
setup: 'Setting up OpenClaw...',
start: 'Starting gateway...',
stop: 'Stopping gateway...',
restart: 'Restarting gateway...',
reconnect: 'Restoring gateway connection...',
const OAUTH_ONLY_TYPES = new Set(['chatgpt-pro', 'github-copilot', 'qwen-code'])
const CUSTOM_ROLE_VALUE = '__custom__'
const PLAIN_AGENT_VALUE = '__plain__'
type AgentCreationMode = 'builtin' | 'custom' | 'plain'
function createDefaultCustomRoleBoundaries(): BrowserOSRoleBoundary[] {
return [
{
key: 'draft-external-comms',
label: 'Draft external communications',
description: 'May prepare outbound messages for review.',
defaultMode: 'allow',
},
{
key: 'send-external-comms',
label: 'Send external communications',
description: 'Should require approval before sending messages.',
defaultMode: 'ask',
},
{
key: 'calendar-mutations',
label: 'Modify calendar events',
description: 'Should ask before moving or creating calendar events.',
defaultMode: 'ask',
},
]
}
function parseCommaSeparatedList(input: string): string[] {
return input
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
const CONTROL_PLANE_COPY: Record<
@@ -249,6 +281,7 @@ export const AgentsPage: FC = () => {
loading: agentsLoading,
error: agentsError,
} = useOpenClawAgents(agentsQueryEnabled)
const { roles, loading: rolesLoading, error: rolesError } = useOpenClawRoles()
const {
setupOpenClaw,
createAgent,
@@ -262,20 +295,48 @@ export const AgentsPage: FC = () => {
creating,
deleting,
reconnecting,
pendingGatewayAction,
} = useOpenClawMutations()
const [setupOpen, setSetupOpen] = useState(false)
const [setupProviderId, setSetupProviderId] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [selectedRoleValue, setSelectedRoleValue] = useState<
| RoleTemplateSummary['id']
| typeof CUSTOM_ROLE_VALUE
| typeof PLAIN_AGENT_VALUE
>('chief-of-staff')
const [newName, setNewName] = useState('')
const [createProviderId, setCreateProviderId] = useState('')
const [customRole, setCustomRole] = useState<BrowserOSCustomRoleInput>({
name: '',
shortDescription: '',
longDescription: '',
recommendedApps: [],
boundaries: createDefaultCustomRoleBoundaries(),
})
const [chatAgent, setChatAgent] = useState<AgentEntry | null>(null)
const [showTerminal, setShowTerminal] = useState(false)
const [error, setError] = useState<string | null>(null)
const compatibleProviders = getOpenClawSupportedProviders(providers)
const compatibleProviders = providers.filter(
(provider) => provider.apiKey && !OAUTH_ONLY_TYPES.has(provider.type),
)
const creationMode: AgentCreationMode =
selectedRoleValue === CUSTOM_ROLE_VALUE
? 'custom'
: selectedRoleValue === PLAIN_AGENT_VALUE
? 'plain'
: 'builtin'
const isCustomRole = creationMode === 'custom'
const isPlainAgent = creationMode === 'plain'
const selectedRole =
creationMode === 'builtin'
? (roles.find((role) => role.id === selectedRoleValue) ??
roles[0] ??
null)
: null
useEffect(() => {
if (compatibleProviders.length === 0) return
@@ -295,17 +356,47 @@ export const AgentsPage: FC = () => {
])
useEffect(() => {
if (!createOpen) return
setNewName((current) => current || 'agent')
}, [createOpen])
if (!createOpen || roles.length === 0) return
const lifecyclePending = pendingGatewayAction !== null
const inlineError = lifecyclePending
? null
: (error ?? statusError?.message ?? agentsError?.message ?? null)
const lifecycleBanner = pendingGatewayAction
? LIFECYCLE_BANNER_COPY[pendingGatewayAction]
: null
const defaultRole = roles.find((role) => role.id === 'chief-of-staff')
const nextRole = defaultRole ?? roles[0]
setSelectedRoleValue((current) => {
if (current === CUSTOM_ROLE_VALUE || current === PLAIN_AGENT_VALUE)
return current
const hasCurrent = roles.some((role) => role.id === current)
return hasCurrent ? current : nextRole.id
})
setNewName((current) => current || nextRole.defaultAgentName)
}, [createOpen, roles])
useEffect(() => {
if (!createOpen) return
if (isCustomRole) {
setNewName(
(current) =>
current || customRole.name.trim().toLowerCase().replace(/\s+/g, '-'),
)
return
}
if (isPlainAgent) {
setNewName((current) => current || 'agent')
return
}
if (selectedRole) {
setNewName((current) => current || selectedRole.defaultAgentName)
}
}, [createOpen, isCustomRole, isPlainAgent, customRole.name, selectedRole])
const inlineError =
error ??
statusError?.message ??
agentsError?.message ??
rolesError?.message ??
null
const gatewayUiState = useMemo(() => {
if (!status) {
@@ -334,10 +425,6 @@ export const AgentsPage: FC = () => {
}
}, [status])
const canManageAgents = gatewayUiState.canManageAgents && !lifecyclePending
const showControlPlaneDegraded =
!lifecyclePending && gatewayUiState.controlPlaneDegraded
const recoveryDetail = status ? getRecoveryDetail(status) : null
const controlPlaneCopy = status
? getControlPlaneCopy(status.controlPlaneStatus)
@@ -375,10 +462,34 @@ export const AgentsPage: FC = () => {
(item) => item.id === createProviderId,
)
const normalizedName = newName.trim().toLowerCase().replace(/\s+/g, '-')
const customRolePayload = isCustomRole
? {
...customRole,
name: customRole.name.trim(),
shortDescription: customRole.shortDescription.trim(),
longDescription: customRole.longDescription.trim(),
}
: undefined
if (
isCustomRole &&
(!customRolePayload?.name ||
!customRolePayload.shortDescription ||
!customRolePayload.longDescription)
) {
setError(
'Custom roles require a role name, short description, and long description.',
)
return
}
if (creationMode === 'builtin' && !selectedRole) return
await runWithErrorHandling(async () => {
await createAgent({
name: normalizedName,
roleId: creationMode === 'builtin' ? selectedRole?.id : undefined,
customRole: isCustomRole ? customRolePayload : undefined,
providerType: provider?.type,
providerName: provider?.name,
baseUrl: provider?.baseUrl,
@@ -387,6 +498,13 @@ export const AgentsPage: FC = () => {
})
setCreateOpen(false)
setNewName('')
setCustomRole({
name: '',
shortDescription: '',
longDescription: '',
recommendedApps: [],
boundaries: createDefaultCustomRoleBoundaries(),
})
})
}
@@ -501,7 +619,7 @@ export const AgentsPage: FC = () => {
</Button>
<Button
onClick={() => setCreateOpen(true)}
disabled={!canManageAgents}
disabled={!gatewayUiState.canManageAgents}
>
<Plus className="mr-1 size-4" />
New Agent
@@ -512,13 +630,6 @@ export const AgentsPage: FC = () => {
)}
</div>
{lifecycleBanner && (
<Alert>
<Loader2 className="animate-spin" />
<AlertTitle>{lifecycleBanner}</AlertTitle>
</Alert>
)}
{inlineError && (
<Alert variant="destructive">
<AlertCircle />
@@ -538,7 +649,7 @@ export const AgentsPage: FC = () => {
</Alert>
)}
{status && showControlPlaneDegraded && (
{status && gatewayUiState.controlPlaneDegraded && (
<Alert
variant={
status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'
@@ -590,8 +701,8 @@ export const AgentsPage: FC = () => {
<h3 className="font-semibold text-lg">Set Up OpenClaw</h3>
<p className="text-muted-foreground text-sm">
{status.podmanAvailable
? 'Create a local BrowserOS VM to run autonomous agents with full tool access.'
: 'BrowserOS VM runtime is unavailable on this system.'}
? 'Create a local container to run autonomous agents with full tool access.'
: 'Podman is required to run OpenClaw agents. Install Podman first.'}
</p>
</div>
{status.podmanAvailable && (
@@ -659,7 +770,7 @@ export const AgentsPage: FC = () => {
<Button
variant="outline"
onClick={() => setCreateOpen(true)}
disabled={!canManageAgents}
disabled={!gatewayUiState.canManageAgents}
>
<Plus className="mr-1 size-4" />
Create Agent
@@ -677,10 +788,20 @@ export const AgentsPage: FC = () => {
<CardTitle className="text-base">
{agent.name}
</CardTitle>
{agent.role && (
<Badge variant="secondary">
{agent.role.roleName}
</Badge>
)}
</div>
<p className="font-mono text-muted-foreground text-xs">
{agent.workspace}
</p>
{agent.role && (
<p className="text-muted-foreground text-xs">
{agent.role.shortDescription}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1">
@@ -688,7 +809,7 @@ export const AgentsPage: FC = () => {
variant="ghost"
size="sm"
onClick={() => setChatAgent(agent)}
disabled={!canManageAgents}
disabled={!gatewayUiState.canManageAgents}
>
<MessageSquare className="mr-1 size-4" />
Chat
@@ -698,7 +819,7 @@ export const AgentsPage: FC = () => {
variant="ghost"
size="icon"
onClick={() => handleDelete(agent.agentId)}
disabled={!canManageAgents || deleting}
disabled={!gatewayUiState.canManageAgents || deleting}
>
<Trash2 className="size-4 text-destructive" />
</Button>
@@ -747,6 +868,246 @@ export const AgentsPage: FC = () => {
<DialogTitle>Create Agent</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="agent-role">
Agent Role
</label>
<Select
value={selectedRoleValue}
onValueChange={(value) => {
if (value === CUSTOM_ROLE_VALUE) {
setSelectedRoleValue(CUSTOM_ROLE_VALUE)
setNewName(
customRole.name
.trim()
.toLowerCase()
.replace(/\s+/g, '-') || 'custom-agent',
)
return
}
if (value === PLAIN_AGENT_VALUE) {
setSelectedRoleValue(PLAIN_AGENT_VALUE)
setNewName('agent')
return
}
const role = roles.find((item) => item.id === value)
if (!role) return
setSelectedRoleValue(role.id)
setNewName(role.defaultAgentName)
}}
disabled={rolesLoading}
>
<SelectTrigger id="agent-role">
<SelectValue
placeholder={
rolesLoading ? 'Loading roles...' : 'Select a role'
}
/>
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))}
<SelectItem value={PLAIN_AGENT_VALUE}>Plain Agent</SelectItem>
<SelectItem value={CUSTOM_ROLE_VALUE}>Custom Role</SelectItem>
</SelectContent>
</Select>
{selectedRole && !isCustomRole && (
<Card>
<CardContent className="space-y-3 py-4">
<div>
<div className="font-medium text-sm">
{selectedRole.name}
</div>
<p className="text-muted-foreground text-xs">
{selectedRole.shortDescription}
</p>
</div>
<div>
<div className="font-medium text-xs">
Recommended Apps
</div>
<p className="text-muted-foreground text-xs">
{selectedRole.recommendedApps.join(', ')}
</p>
</div>
<div>
<div className="font-medium text-xs">
Default Boundaries
</div>
<ul className="space-y-1 text-muted-foreground text-xs">
{selectedRole.boundaries.map((boundary) => (
<li key={boundary.key}>
{boundary.label}: {boundary.defaultMode}
</li>
))}
</ul>
</div>
</CardContent>
</Card>
)}
{isPlainAgent && (
<Card>
<CardContent className="space-y-2 py-4">
<div className="font-medium text-sm">Plain Agent</div>
<p className="text-muted-foreground text-xs">
No role bootstrap or defaults. Intended for temporary
development and testing only.
</p>
</CardContent>
</Card>
)}
</div>
{isCustomRole && (
<Card>
<CardContent className="space-y-4 py-4">
<div className="space-y-2">
<label
htmlFor="custom-role-name"
className="font-medium text-sm"
>
Custom Role Name
</label>
<Input
id="custom-role-name"
value={customRole.name}
onChange={(event) => {
const name = event.target.value
setCustomRole((current) => ({ ...current, name }))
setNewName(
name.trim().toLowerCase().replace(/\s+/g, '-') ||
'custom-agent',
)
}}
placeholder="Board Prep Operator"
/>
</div>
<div className="space-y-2">
<label
htmlFor="custom-role-short-description"
className="font-medium text-sm"
>
Short Description
</label>
<Input
id="custom-role-short-description"
value={customRole.shortDescription}
onChange={(event) =>
setCustomRole((current) => ({
...current,
shortDescription: event.target.value,
}))
}
placeholder="Prepares executive briefs and weekly follow-ups."
/>
</div>
<div className="space-y-2">
<label
htmlFor="custom-role-long-description"
className="font-medium text-sm"
>
Long Description
</label>
<Textarea
id="custom-role-long-description"
value={customRole.longDescription}
onChange={(event) =>
setCustomRole((current) => ({
...current,
longDescription: event.target.value,
}))
}
placeholder="Describe the role, purpose, and what kinds of outcomes this agent should produce."
rows={4}
/>
</div>
<div className="space-y-2">
<label
htmlFor="custom-role-apps"
className="font-medium text-sm"
>
Recommended Apps
</label>
<Input
id="custom-role-apps"
value={customRole.recommendedApps.join(', ')}
onChange={(event) =>
setCustomRole((current) => ({
...current,
recommendedApps: parseCommaSeparatedList(
event.target.value,
),
}))
}
placeholder="gmail, slack, notion"
/>
<p className="text-muted-foreground text-xs">
Comma-separated. Used as role guidance only in this
milestone.
</p>
</div>
<div className="space-y-3">
<div>
<div className="font-medium text-sm">
Boundary Defaults
</div>
<p className="text-muted-foreground text-xs">
Set the starting behavior for common high-impact
actions.
</p>
</div>
{customRole.boundaries.map((boundary) => (
<div
key={boundary.key}
className="grid gap-2 rounded-lg border p-3"
>
<div>
<div className="font-medium text-sm">
{boundary.label}
</div>
<p className="text-muted-foreground text-xs">
{boundary.description}
</p>
</div>
<Select
value={boundary.defaultMode}
onValueChange={(value) =>
setCustomRole((current) => ({
...current,
boundaries: current.boundaries.map((item) =>
item.key === boundary.key
? {
...item,
defaultMode:
value as BrowserOSRoleBoundary['defaultMode'],
}
: item,
),
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="allow">Allow</SelectItem>
<SelectItem value="ask">Ask</SelectItem>
<SelectItem value="block">Block</SelectItem>
</SelectContent>
</Select>
</div>
))}
</div>
</CardContent>
</Card>
)}
<div>
<label
htmlFor="agent-name"
@@ -780,8 +1141,10 @@ export const AgentsPage: FC = () => {
disabled={
!newName.trim() ||
creating ||
!canManageAgents ||
compatibleProviders.length === 0
rolesLoading ||
!gatewayUiState.canManageAgents ||
compatibleProviders.length === 0 ||
(creationMode === 'builtin' && !selectedRole)
}
className="w-full"
>

View File

@@ -1,24 +0,0 @@
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
const OPENCLAW_SUPPORTED_PROVIDER_TYPES: ProviderType[] = [
'openrouter',
'openai',
'openai-compatible',
'anthropic',
'moonshot',
]
export function isOpenClawSupportedProviderType(
providerType: ProviderType,
): boolean {
return OPENCLAW_SUPPORTED_PROVIDER_TYPES.includes(providerType)
}
export function getOpenClawSupportedProviders(
providers: LlmProviderConfig[],
): LlmProviderConfig[] {
return providers.filter(
(provider) =>
!!provider.apiKey && isOpenClawSupportedProviderType(provider.type),
)
}

View File

@@ -1,3 +1,7 @@
import type {
BrowserOSAgentRoleId,
BrowserOSCustomRoleInput,
} from '@browseros/shared/types/role-aware-agents'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
@@ -7,6 +11,27 @@ export interface AgentEntry {
name: string
workspace: string
model?: unknown
role?: {
roleSource: 'builtin' | 'custom'
roleId?: BrowserOSAgentRoleId
roleName: string
shortDescription: string
}
}
export interface RoleTemplateSummary {
id: BrowserOSAgentRoleId
name: string
shortDescription: string
longDescription: string
recommendedApps: string[]
defaultAgentName: string
boundaries: Array<{
key: string
label: string
description: string
defaultMode: 'allow' | 'ask' | 'block'
}>
}
export interface OpenClawStatus {
@@ -36,6 +61,8 @@ export interface OpenClawStatus {
export interface OpenClawAgentMutationInput {
name: string
roleId?: BrowserOSAgentRoleId
customRole?: BrowserOSCustomRoleInput
providerType?: string
providerName?: string
baseUrl?: string
@@ -59,15 +86,9 @@ export function getModelDisplayName(model: unknown): string | undefined {
export const OPENCLAW_QUERY_KEYS = {
status: 'openclaw-status',
agents: 'openclaw-agents',
roles: 'openclaw-roles',
} as const
export type GatewayLifecycleAction =
| 'setup'
| 'start'
| 'stop'
| 'restart'
| 'reconnect'
async function clawFetch<T>(
baseUrl: string,
path: string,
@@ -96,6 +117,16 @@ async function fetchOpenClawAgents(baseUrl: string): Promise<AgentEntry[]> {
return data.agents ?? []
}
async function fetchOpenClawRoles(
baseUrl: string,
): Promise<RoleTemplateSummary[]> {
const data = await clawFetch<{ roles: RoleTemplateSummary[] }>(
baseUrl,
'/roles',
)
return data.roles ?? []
}
async function invalidateOpenClawQueries(
queryClient: ReturnType<typeof useQueryClient>,
): Promise<void> {
@@ -148,6 +179,28 @@ export function useOpenClawAgents(enabled = true) {
}
}
export function useOpenClawRoles() {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<RoleTemplateSummary[], Error>({
queryKey: [OPENCLAW_QUERY_KEYS.roles, baseUrl],
queryFn: () => fetchOpenClawRoles(baseUrl as string),
enabled: !!baseUrl && !urlLoading,
staleTime: 60_000,
})
return {
roles: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useOpenClawMutations() {
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
const queryClient = useQueryClient()
@@ -225,13 +278,6 @@ export function useOpenClawMutations() {
onSuccess,
})
let pendingGatewayAction: GatewayLifecycleAction | null = null
if (setupMutation.isPending) pendingGatewayAction = 'setup'
else if (restartMutation.isPending) pendingGatewayAction = 'restart'
else if (stopMutation.isPending) pendingGatewayAction = 'stop'
else if (startMutation.isPending) pendingGatewayAction = 'start'
else if (reconnectMutation.isPending) pendingGatewayAction = 'reconnect'
return {
setupOpenClaw: setupMutation.mutateAsync,
createAgent: createMutation.mutateAsync,
@@ -252,7 +298,6 @@ export function useOpenClawMutations() {
creating: createMutation.isPending,
deleting: deleteMutation.isPending,
reconnecting: reconnectMutation.isPending,
pendingGatewayAction,
}
}
@@ -269,60 +314,17 @@ export interface OpenClawStreamEvent {
data: Record<string, unknown>
}
export interface OpenClawChatHistoryMessage {
role: 'user' | 'assistant'
content: string
}
interface ChatHistoryTurnLike {
userText: string
parts: Array<{ kind: string; text?: string }>
}
export function buildChatHistoryFromTurns(
turns: ChatHistoryTurnLike[],
): OpenClawChatHistoryMessage[] {
const messages: OpenClawChatHistoryMessage[] = []
for (const turn of turns) {
const userText = turn.userText.trim()
if (userText) {
messages.push({ role: 'user', content: userText })
}
const assistantText = turn.parts
.filter(
(
part,
): part is {
kind: 'text'
text: string
} => part.kind === 'text' && typeof part.text === 'string',
)
.map((part) => part.text.trim())
.filter(Boolean)
.join('\n\n')
if (assistantText) {
messages.push({ role: 'assistant', content: assistantText })
}
}
return messages
}
export async function chatWithAgent(
agentId: string,
message: string,
sessionKey?: string,
history: OpenClawChatHistoryMessage[] = [],
signal?: AbortSignal,
): Promise<Response> {
const baseUrl = await getAgentServerUrl()
return fetch(`${baseUrl}/claw/agents/${agentId}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, sessionKey, history }),
body: JSON.stringify({ message, sessionKey }),
signal,
})
}

View File

@@ -7,9 +7,8 @@ import { PRODUCT_WEB_HOST } from './lib/constants/productWebHost'
// biome-ignore lint/style/noProcessEnv: build config file needs env access
const env = process.env
const apiUrl = new URL(
env.VITE_PUBLIC_BROWSEROS_API?.trim() || 'https://api.browseros.com',
)
// biome-ignore lint/style/noNonNullAssertion: required env var
const apiUrl = new URL(env.VITE_PUBLIC_BROWSEROS_API!)
const apiPattern = apiUrl.port
? `${apiUrl.hostname}:${apiUrl.port}`
: apiUrl.hostname

View File

@@ -142,7 +142,7 @@ cp .env.example .env.development
bun run start
```
See the [agent monorepo README](../../README.md) for full environment variable reference and `dev:watch` setup.
See the [agent monorepo README](../../README.md) for full environment variable reference and `process-compose` setup.
### Testing

View File

@@ -1,6 +1,6 @@
{
"name": "@browseros/server",
"version": "0.0.88",
"version": "0.0.85",
"description": "BrowserOS server",
"type": "module",
"main": "./src/index.ts",
@@ -10,21 +10,9 @@
"scripts": {
"start": "bun --watch --env-file=.env.development src/index.ts",
"build": "bun ../../scripts/build/server.ts --target=all",
"test": "bun run test:all",
"test:all": "bun run ./tests/__helpers__/run-test-group.ts all",
"test:agent": "bun run ./tests/__helpers__/run-test-group.ts agent",
"test:api": "bun run ./tests/__helpers__/run-test-group.ts api",
"test:browser": "bun run ./tests/__helpers__/run-test-group.ts browser",
"test:cdp": "bun run test:browser",
"test:core": "bun run ./tests/__helpers__/run-test-group.ts core",
"test:integration": "bun run ./tests/__helpers__/run-test-group.ts integration",
"test:root": "bun run ./tests/__helpers__/run-test-group.ts root",
"test:sdk": "bun run ./tests/__helpers__/run-test-group.ts sdk",
"test:skills": "bun run ./tests/__helpers__/run-test-group.ts skills",
"test:tools": "bun run ./tests/__helpers__/run-test-group.ts tools",
"test:tools:acl": "bun run test:cleanup && bun --env-file=.env.development test ./tests/tools/acl-scorer.test.ts",
"test:tools:filesystem": "bun run test:cleanup && bun --env-file=.env.development test ./tests/tools/filesystem",
"test:tools:input": "bun run test:cleanup && bun --env-file=.env.development test ./tests/tools/input.test.ts",
"test:tools": "bun run test:cleanup && bun --env-file=.env.development test tests/tools",
"test:integration": "bun run test:cleanup && bun --env-file=.env.development test tests/server.integration.test.ts",
"test:sdk": "echo 'SDK tests disabled: test environment does not provide the extract/verify LLM service'",
"test:cleanup": "./tests/__helpers__/cleanup.sh",
"typecheck": "tsc --noEmit",
"devtools": "bunx @ai-sdk/devtools"

View File

@@ -0,0 +1,37 @@
services:
openclaw-gateway:
image: ${OPENCLAW_IMAGE:-ghcr.io/openclaw/openclaw:latest}
ports:
- "127.0.0.1:${OPENCLAW_GATEWAY_PORT:-18789}:18789"
environment:
- HOME=/home/node
- NODE_ENV=production
- OPENCLAW_GATEWAY_TOKEN=${OPENCLAW_GATEWAY_TOKEN}
- OPENCLAW_GATEWAY_BIND=lan
- TZ=${TZ}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
- GROQ_API_KEY=${GROQ_API_KEY:-}
- MISTRAL_API_KEY=${MISTRAL_API_KEY:-}
- MOONSHOT_API_KEY=${MOONSHOT_API_KEY:-}
volumes:
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
extra_hosts:
- "host.containers.internal:host-gateway"
command:
- node
- dist/index.js
- gateway
- --bind
- lan
- --port
- "18789"
- --allow-unconfigured
healthcheck:
test: ["CMD", "curl", "-sf", "http://127.0.0.1:18789/healthz"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped

View File

@@ -10,7 +10,6 @@ import type { Browser } from '../../browser/browser'
import { logger } from '../../lib/logger'
import { metrics } from '../../lib/metrics'
import { Sentry } from '../../lib/sentry'
import { getMonitoringService } from '../../monitoring/service'
import type { ToolRegistry } from '../../tools/tool-registry'
import type { GlobalAclPolicyService } from '../services/acl/global-acl-policy'
import { resolveAclPolicyForMcpRequest } from '../services/acl/resolve-acl-policy'
@@ -40,35 +39,16 @@ export function createMcpRoutes(deps: McpRouteDeps) {
app.post('/', async (c) => {
const scopeId = c.req.header('X-BrowserOS-Scope-Id') || 'ephemeral'
const monitoringService = getMonitoringService()
const explicitAgentId =
c.req.query('agentId') ??
c.req.header('X-BrowserOS-Agent-Id') ??
undefined
const activeSession = explicitAgentId
? {
agentId: explicitAgentId,
monitoringSessionId:
monitoringService.getActiveSessionId(explicitAgentId),
}
: monitoringService.getSingleActiveSession()
const agentId = activeSession?.agentId
metrics.log('mcp.request', { scopeId })
const aclRules = await resolveAclPolicyForMcpRequest({
policyService: deps.policyService,
})
const monitoringSessionId = activeSession?.monitoringSessionId
const observer =
monitoringSessionId && agentId
? monitoringService.createObserver(monitoringSessionId, agentId)
: undefined
// Per-request server + transport: no shared state, no race conditions,
// no ID collisions. Required by MCP SDK 1.26.0+ security fix (GHSA-345p-7cg4-v4c7).
const mcpServer = createMcpServer({
...deps,
aclRules,
observer,
})
const transport = new StreamableHTTPTransport({
sessionIdGenerator: undefined,
@@ -82,9 +62,6 @@ export function createMcpRoutes(deps: McpRouteDeps) {
Sentry.withScope((scope) => {
scope.setTag('route', 'mcp')
scope.setTag('scopeId', scopeId)
if (agentId) {
scope.setTag('agentId', agentId)
}
Sentry.captureException(error)
})
logger.error('Error handling MCP request', {

View File

@@ -1,113 +0,0 @@
import { Hono } from 'hono'
import { getMonitoringService } from '../../monitoring/service'
import { isValidMonitoringRunId } from '../../monitoring/storage'
export function createMonitoringRoutes() {
return new Hono()
.get('/runs', async (c) => {
const limitParam = c.req.query('limit')
const parsedLimit = limitParam ? Number.parseInt(limitParam, 10) : 50
const limit =
Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 50
const runs = await getMonitoringService().listRuns(limit)
return c.json({ runs })
})
.get('/runs/:id', async (c) => {
const runId = c.req.param('id')
if (!isValidMonitoringRunId(runId)) {
return c.json({ error: 'Invalid monitoring run id' }, 400)
}
const envelope = await getMonitoringService().getRunEnvelope(runId)
if (!envelope) {
return c.json({ error: 'Monitoring run not found' }, 404)
}
return c.json({ run: envelope })
})
.post('/debug/runs', async (c) => {
const body = await c.req.json<{
agentId?: string
sessionKey?: string
originalPrompt?: string
chatHistory?: Array<{ role?: 'user' | 'assistant'; content?: string }>
}>()
if (!body.agentId?.trim()) {
return c.json({ error: 'agentId is required' }, 400)
}
if (!body.sessionKey?.trim()) {
return c.json({ error: 'sessionKey is required' }, 400)
}
if (!body.originalPrompt?.trim()) {
return c.json({ error: 'originalPrompt is required' }, 400)
}
const chatHistory = Array.isArray(body.chatHistory)
? body.chatHistory
.filter(
(turn): turn is { role: 'user' | 'assistant'; content: string } =>
(turn.role === 'user' || turn.role === 'assistant') &&
typeof turn.content === 'string',
)
.map((turn) => ({
role: turn.role,
content: turn.content,
}))
: []
const session = await getMonitoringService().startSession({
agentId: body.agentId.trim(),
sessionKey: body.sessionKey.trim(),
originalPrompt: body.originalPrompt.trim(),
chatHistory,
source: 'debug',
})
return c.json({ session }, 201)
})
.post('/debug/runs/:id/finalize', async (c) => {
const runId = c.req.param('id')
if (!isValidMonitoringRunId(runId)) {
return c.json({ error: 'Invalid monitoring run id' }, 400)
}
const body = await c.req.json<{
agentId?: string
sessionKey?: string
status?: 'completed' | 'failed' | 'aborted' | 'incomplete'
finalAssistantMessage?: string
error?: string
}>()
if (!body.agentId?.trim()) {
return c.json({ error: 'agentId is required' }, 400)
}
if (!body.sessionKey?.trim()) {
return c.json({ error: 'sessionKey is required' }, 400)
}
if (
body.status !== 'completed' &&
body.status !== 'failed' &&
body.status !== 'aborted' &&
body.status !== 'incomplete'
) {
return c.json({ error: 'status is invalid' }, 400)
}
const envelope = await getMonitoringService().finalizeSession({
monitoringSessionId: runId,
agentId: body.agentId.trim(),
sessionKey: body.sessionKey.trim(),
status: body.status,
finalAssistantMessage: body.finalAssistantMessage,
error: body.error,
})
if (!envelope) {
return c.json({ error: 'Monitoring run not found' }, 404)
}
return c.json({ run: envelope })
})
}

View File

@@ -7,26 +7,38 @@
* Thin layer delegating to OpenClawService.
*/
import { OPENCLAW_GATEWAY_PORT } from '@browseros/shared/constants/openclaw'
import { BROWSEROS_ROLE_TEMPLATES } from '@browseros/shared/constants/role-aware-agents'
import type {
BrowserOSAgentRoleId,
BrowserOSCustomRoleInput,
} from '@browseros/shared/types/role-aware-agents'
import { Hono } from 'hono'
import { stream } from 'hono/streaming'
import { logger } from '../../lib/logger'
import { getMonitoringService } from '../../monitoring/service'
import type { MonitoringChatTurn } from '../../monitoring/types'
import {
OpenClawAgentAlreadyExistsError,
OpenClawAgentNotFoundError,
OpenClawInvalidAgentNameError,
OpenClawProtectedAgentError,
OpenClawSessionNotFoundError,
} from '../services/openclaw/errors'
import { isUnsupportedOpenClawProviderError } from '../services/openclaw/openclaw-provider-map'
import { getOpenClawService } from '../services/openclaw/openclaw-service'
function getCreateAgentValidationError(body: { name?: string }): string | null {
if (!body.name?.trim()) {
return 'Name is required'
}
return null
function isValidBoundaryMode(
value: unknown,
): value is BrowserOSCustomRoleInput['boundaries'][number]['defaultMode'] {
return value === 'allow' || value === 'ask' || value === 'block'
}
function isValidCustomRoleBoundary(value: unknown): boolean {
if (!value || typeof value !== 'object') return false
const boundary = value as Record<string, unknown>
return (
typeof boundary.key === 'string' &&
typeof boundary.label === 'string' &&
typeof boundary.description === 'string' &&
isValidBoundaryMode(boundary.defaultMode)
)
}
export function createOpenClawRoutes() {
@@ -60,7 +72,7 @@ export function createOpenClawRoutes() {
return c.json(
{
status: 'running',
port: getOpenClawService().getPort(),
port: OPENCLAW_GATEWAY_PORT,
agents: agents.map((a) => ({
agentId: a.agentId,
name: a.name,
@@ -77,10 +89,7 @@ export function createOpenClawRoutes() {
providerType: body.providerType,
providerName: body.providerName,
})
if (isUnsupportedOpenClawProviderError(err)) {
return c.json({ error: err.message }, 400)
}
if (message.includes('VM runtime is not available')) {
if (message.includes('Podman is not available')) {
return c.json({ error: message }, 503)
}
return c.json({ error: message }, 500)
@@ -145,23 +154,97 @@ export function createOpenClawRoutes() {
}
})
.get('/roles', async (c) => {
return c.json({
roles: BROWSEROS_ROLE_TEMPLATES.map((role) => ({
id: role.id,
name: role.name,
shortDescription: role.shortDescription,
longDescription: role.longDescription,
recommendedApps: role.recommendedApps,
boundaries: role.boundaries,
defaultAgentName: role.defaultAgentName,
})),
})
})
.post('/agents', async (c) => {
const body = await c.req.json<{
name: string
roleId?: BrowserOSAgentRoleId
customRole?: BrowserOSCustomRoleInput
providerType?: string
providerName?: string
baseUrl?: string
apiKey?: string
modelId?: string
}>()
const validationError = getCreateAgentValidationError(body)
if (validationError) {
return c.json({ error: validationError }, 400)
const name = body.name?.trim()
if (!name) {
return c.json({ error: 'Name is required' }, 400)
}
if (body.roleId && body.customRole) {
return c.json(
{ error: 'Provide either roleId or customRole, not both' },
400,
)
}
if (
body.customRole &&
(!body.customRole.name?.trim() ||
!body.customRole.shortDescription?.trim() ||
!body.customRole.longDescription?.trim())
) {
return c.json(
{
error:
'Custom roles require name, shortDescription, and longDescription',
},
400,
)
}
if (
body.customRole &&
(!Array.isArray(body.customRole.recommendedApps) ||
!Array.isArray(body.customRole.boundaries))
) {
return c.json(
{
error: 'Custom roles require recommendedApps and boundaries arrays',
},
400,
)
}
if (
body.customRole &&
!body.customRole.recommendedApps.every((app) => typeof app === 'string')
) {
return c.json(
{
error: 'Custom role recommendedApps must be an array of strings',
},
400,
)
}
if (
body.customRole &&
!body.customRole.boundaries.every(isValidCustomRoleBoundary)
) {
return c.json(
{
error:
'Custom role boundaries must include key, label, description, and a valid defaultMode',
},
400,
)
}
try {
const agent = await getOpenClawService().createAgent({
name: body.name.trim(),
name,
roleId: body.roleId,
customRole: body.customRole,
providerType: body.providerType,
providerName: body.providerName,
baseUrl: body.baseUrl,
@@ -176,9 +259,6 @@ export function createOpenClawRoutes() {
if (err instanceof OpenClawInvalidAgentNameError) {
return c.json({ error: err.message }, 400)
}
if (isUnsupportedOpenClawProviderError(err)) {
return c.json({ error: err.message }, 400)
}
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
@@ -207,7 +287,6 @@ export function createOpenClawRoutes() {
const body = await c.req.json<{
message: string
sessionKey?: string
history?: MonitoringChatTurn[]
}>()
if (!body.message?.trim()) {
@@ -215,37 +294,12 @@ export function createOpenClawRoutes() {
}
const sessionKey = body.sessionKey ?? crypto.randomUUID()
const history = Array.isArray(body.history)
? body.history.filter((entry): entry is MonitoringChatTurn =>
Boolean(
entry &&
(entry.role === 'user' || entry.role === 'assistant') &&
typeof entry.content === 'string',
),
)
: []
if (getMonitoringService().getActiveSessionId(id)) {
return c.json(
{
error:
'A monitored chat session is already active for this agent. Wait for it to finish before starting another.',
},
409,
)
}
const monitoringContext = await getMonitoringService().startSession({
agentId: id,
sessionKey,
originalPrompt: body.message.trim(),
chatHistory: history,
})
try {
const eventStream = await getOpenClawService().chatStream(
id,
sessionKey,
body.message,
history,
)
c.header('Content-Type', 'text/event-stream')
@@ -255,123 +309,20 @@ export function createOpenClawRoutes() {
return stream(c, async (s) => {
const reader = eventStream.getReader()
const encoder = new TextEncoder()
let finalAssistantMessage: string | undefined
let status: 'completed' | 'failed' | 'aborted' | 'incomplete' =
'incomplete'
let finalError: string | undefined
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (
value.type === 'done' &&
typeof value.data.text === 'string' &&
value.data.text.trim()
) {
finalAssistantMessage = value.data.text
status = 'completed'
}
if (value.type === 'error') {
finalError =
(typeof value.data.message === 'string'
? value.data.message
: typeof value.data.error === 'string'
? value.data.error
: undefined) ?? 'Unknown chat stream error'
status = 'failed'
}
await s.write(
encoder.encode(`data: ${JSON.stringify(value)}\n\n`),
)
}
await s.write(encoder.encode('data: [DONE]\n\n'))
} catch (error) {
if (c.req.raw.signal.aborted) {
status = 'aborted'
} else {
status = 'failed'
finalError =
error instanceof Error ? error.message : String(error)
}
throw error
} finally {
await reader.cancel()
await getMonitoringService().finalizeSession({
monitoringSessionId: monitoringContext.monitoringSessionId,
agentId: id,
sessionKey,
status,
finalAssistantMessage,
error: finalError,
})
}
})
} catch (err) {
await getMonitoringService().finalizeSession({
monitoringSessionId: monitoringContext.monitoringSessionId,
agentId: id,
sessionKey,
status: c.req.raw.signal.aborted ? 'aborted' : 'failed',
error: err instanceof Error ? err.message : String(err),
})
if (isUnsupportedOpenClawProviderError(err)) {
return c.json({ error: err.message }, 400)
}
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.get('/session/:key/history', async (c) => {
const key = c.req.param('key')
const limitRaw = c.req.query('limit')
const cursor = c.req.query('cursor')
const limitParsed =
limitRaw !== undefined ? Number.parseInt(limitRaw, 10) : Number.NaN
const limit = Number.isFinite(limitParsed) ? limitParsed : undefined
const wantsStream = (c.req.header('accept') ?? '').includes(
'text/event-stream',
)
try {
if (!wantsStream) {
const history = await getOpenClawService().getSessionHistory(key, {
limit,
cursor,
})
return c.json(history)
}
const eventStream = await getOpenClawService().streamSessionHistory(
key,
{ limit, cursor, signal: c.req.raw.signal },
)
c.header('Content-Type', 'text/event-stream')
c.header('Cache-Control', 'no-cache')
c.header('X-Session-Key', key)
return stream(c, async (s) => {
const reader = eventStream.getReader()
const encoder = new TextEncoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
await s.write(
encoder.encode(
`event: ${value.type}\ndata: ${JSON.stringify(value.data)}\n\n`,
),
)
}
} finally {
await reader.cancel()
}
})
} catch (err) {
if (err instanceof OpenClawSessionNotFoundError) {
return c.json({ error: err.message }, 404)
}
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
@@ -401,17 +352,12 @@ export function createOpenClawRoutes() {
}
try {
const result = await getOpenClawService().updateProviderKeys(body)
await getOpenClawService().updateProviderKeys(body)
return c.json({
status: result.restarted ? 'restarting' : 'updated',
message: result.restarted
? 'Provider updated, restarting gateway'
: 'Provider updated without a restart',
status: 'restarting',
message: 'Provider updated, restarting gateway',
})
} catch (err) {
if (isUnsupportedOpenClawProviderError(err)) {
return c.json({ error: err.message }, 400)
}
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}

View File

@@ -16,9 +16,7 @@ export const TERMINAL_WS_PATH = '/terminal/ws'
interface TerminalRouteDeps {
containerName: string
limaHome: string
limactlPath: string
vmName: string
podmanPath: string
}
function safeSend(ws: { send(data: string): void }, data: string): void {
@@ -47,9 +45,7 @@ function createSocketEvents(deps: TerminalRouteDeps) {
try {
session = createTerminalSession({
containerName: deps.containerName,
limaHome: deps.limaHome,
limactlPath: deps.limactlPath,
vmName: deps.vmName,
podmanPath: deps.podmanPath,
workingDir: TERMINAL_HOME_DIR,
onOutput(data) {
sendOutput(ws, data)

View File

@@ -22,7 +22,6 @@ import { initializeOAuth } from '../lib/clients/oauth'
import { getDb } from '../lib/db'
import { logger } from '../lib/logger'
import { Sentry } from '../lib/sentry'
import { getLimaHomeDir, resolveBundledLimactl, VM_NAME } from '../lib/vm'
import { createAclRoutes } from './routes/acl'
import { createChatRoutes } from './routes/chat'
import { createCreditsRoutes } from './routes/credits'
@@ -30,7 +29,6 @@ import { createHealthRoute } from './routes/health'
import { createKlavisRoutes } from './routes/klavis'
import { createMcpRoutes } from './routes/mcp'
import { createMemoryRoutes } from './routes/memory'
import { createMonitoringRoutes } from './routes/monitoring'
import { createOAuthRoutes } from './routes/oauth'
import { createOpenClawRoutes } from './routes/openclaw'
import { createProviderRoutes } from './routes/provider'
@@ -46,6 +44,7 @@ import {
connectKlavisInBackground,
type KlavisProxyRef,
} from './services/klavis/strata-proxy'
import { getPodmanRuntime } from './services/openclaw/podman-runtime'
import type { Env, HttpServerConfig } from './types'
import { defaultCorsConfig } from './utils/cors'
import { requireTrustedAppOrigin } from './utils/request-auth'
@@ -114,9 +113,7 @@ export async function createHttpServer(config: HttpServerConfig) {
'/',
createTerminalRoutes({
containerName: OPENCLAW_GATEWAY_CONTAINER_NAME,
limaHome: getLimaHomeDir(),
limactlPath: resolveBundledLimactl(resourcesDir),
vmName: VM_NAME,
podmanPath: getPodmanRuntime().getPodmanPath(),
}),
)
@@ -124,10 +121,6 @@ export async function createHttpServer(config: HttpServerConfig) {
.use('/*', requireTrustedAppOrigin())
.route('/', createAclRoutes({ policyService: aclPolicyService }))
const monitoringRoutes = new Hono<Env>()
.use('/*', requireTrustedAppOrigin())
.route('/', createMonitoringRoutes())
const app = new Hono<Env>()
.use('/*', cors(defaultCorsConfig))
.route('/health', createHealthRoute({ browser }))
@@ -150,7 +143,6 @@ export async function createHttpServer(config: HttpServerConfig) {
.route('/soul', createSoulRoutes())
.route('/memory', createMemoryRoutes())
.route('/skills', createSkillsRoutes())
.route('/monitoring', monitoringRoutes)
.route('/acl-rules', aclRoutes)
.route('/test-provider', createProviderRoutes({ browserosId }))
.route('/refine-prompt', createRefinePromptRoutes({ browserosId }))

View File

@@ -20,7 +20,6 @@ import { KlavisClient } from '../../../lib/clients/klavis/klavis-client'
import { OAUTH_MCP_SERVERS } from '../../../lib/clients/klavis/oauth-mcp-servers'
import { logger } from '../../../lib/logger'
import { metrics } from '../../../lib/metrics'
import type { ToolExecutionObserver } from '../../../monitoring/observer'
import { klavisStrataCache } from './strata-cache'
function withTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
@@ -238,7 +237,6 @@ export function buildKlavisToolSet(handle: KlavisProxyHandle): ToolSet {
export function registerKlavisTools(
mcpServer: McpServer,
handle: KlavisProxyHandle,
observer?: ToolExecutionObserver,
): void {
mcpServer.registerTool(
'connector_mcp_servers',
@@ -249,16 +247,9 @@ export function registerKlavisTools(
},
async (args: Record<string, unknown>) => {
const startTime = performance.now()
const toolCallId = crypto.randomUUID()
const server_name = args.server_name as string
try {
await observer?.onToolStart({
toolCallId,
toolName: 'connector_mcp_servers',
source: 'klavis-tool',
args,
})
const klavisClient = new KlavisClient()
const integrations = await klavisClient.getUserIntegrations(
handle.browserosId,
@@ -275,14 +266,6 @@ export function registerKlavisTools(
success: true,
})
await observer?.onToolEnd({
toolCallId,
output: {
connected: true,
server_name,
},
})
return {
content: [
{
@@ -311,15 +294,6 @@ export function registerKlavisTools(
success: true,
})
await observer?.onToolEnd({
toolCallId,
output: {
connected: false,
server_name,
authUrl,
},
})
return {
content: [
{
@@ -346,11 +320,6 @@ export function registerKlavisTools(
error_message: errorText,
})
await observer?.onToolEnd({
toolCallId,
error: errorText,
})
return {
content: [{ type: 'text' as const, text: errorText }],
isError: true,
@@ -370,14 +339,7 @@ export function registerKlavisTools(
},
async (args: Record<string, unknown>) => {
const startTime = performance.now()
const toolCallId = crypto.randomUUID()
try {
await observer?.onToolStart({
toolCallId,
toolName: tool.name,
source: 'klavis-tool',
args,
})
const result = await handle.callTool(tool.name, args)
metrics.log('tool_executed', {
@@ -387,12 +349,6 @@ export function registerKlavisTools(
success: !result.isError,
})
await observer?.onToolEnd({
toolCallId,
output: result,
error: result.isError ? 'Tool returned isError=true' : undefined,
})
return result
} catch (error) {
const errorText =
@@ -406,11 +362,6 @@ export function registerKlavisTools(
error_message: errorText,
})
await observer?.onToolEnd({
toolCallId,
error: errorText,
})
return {
content: [{ type: 'text' as const, text: errorText }],
isError: true,

View File

@@ -8,7 +8,6 @@ import type { AclRule } from '@browseros/shared/types/acl'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import type { Browser } from '../../../browser/browser'
import type { ToolExecutionObserver } from '../../../monitoring/observer'
import type { ToolRegistry } from '../../../tools/tool-registry'
import {
type KlavisProxyRef,
@@ -25,7 +24,6 @@ export interface McpServiceDeps {
resourcesDir: string
aclRules?: AclRule[]
klavisRef?: KlavisProxyRef
observer?: ToolExecutionObserver
}
export function createMcpServer(deps: McpServiceDeps): McpServer {
@@ -50,12 +48,11 @@ export function createMcpServer(deps: McpServiceDeps): McpServer {
resourcesDir: deps.resourcesDir,
},
aclRules: deps.aclRules,
observer: deps.observer,
})
// Register Klavis proxy tools (if connected via background init)
if (deps.klavisRef?.handle) {
registerKlavisTools(server, deps.klavisRef.handle, deps.observer)
registerKlavisTools(server, deps.klavisRef.handle)
}
return server

View File

@@ -1,14 +1,13 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { logger } from '../../../lib/logger'
import { metrics } from '../../../lib/metrics'
import type { ToolExecutionObserver } from '../../../monitoring/observer'
import { executeTool, type ToolContext } from '../../../tools/framework'
import type { ToolRegistry } from '../../../tools/tool-registry'
export function registerTools(
mcpServer: McpServer,
registry: ToolRegistry,
ctx: ToolContext & { observer?: ToolExecutionObserver },
ctx: ToolContext,
): void {
for (const tool of registry.all()) {
const handler = async (
@@ -16,16 +15,9 @@ export function registerTools(
extra: { signal: AbortSignal },
) => {
const startTime = performance.now()
const toolCallId = crypto.randomUUID()
try {
logger.info(`${tool.name} request: ${JSON.stringify(args, null, ' ')}`)
await ctx.observer?.onToolStart({
toolCallId,
toolName: tool.name,
source: 'browser-tool',
args,
})
const result = await executeTool(tool, args, ctx, extra.signal)
@@ -36,12 +28,6 @@ export function registerTools(
source: 'mcp',
})
await ctx.observer?.onToolEnd({
toolCallId,
output: result.structuredContent ?? result.content,
error: result.isError ? 'Tool returned isError=true' : undefined,
})
return {
content: result.content,
isError: result.isError,
@@ -58,11 +44,6 @@ export function registerTools(
source: 'mcp',
})
await ctx.observer?.onToolEnd({
toolCallId,
error: errorText,
})
return {
content: [{ type: 'text' as const, text: errorText }],
isError: true,

View File

@@ -1,193 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { cpSync, existsSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { getBrowserosDir } from '../../../lib/browseros-dir'
import { ContainerCli, ImageLoader } from '../../../lib/container'
import { logger } from '../../../lib/logger'
import {
detectArch,
getLimaHomeDir,
resolveBundledLimactl,
resolveBundledLimaTemplate,
VM_NAME,
VmRuntime,
} from '../../../lib/vm'
import { readCachedManifest } from '../../../lib/vm/manifest'
import { VM_TELEMETRY_EVENTS } from '../../../lib/vm/telemetry'
import { ContainerRuntime } from './container-runtime'
const UNSUPPORTED_PLATFORM_MESSAGE =
'browseros-vm currently supports macOS only; see the Linux/Windows tracking issue'
export interface ContainerRuntimeFactoryInput {
resourcesDir?: string
projectDir: string
browserosRoot?: string
platform?: NodeJS.Platform
}
export function buildContainerRuntime(
input: ContainerRuntimeFactoryInput,
): ContainerRuntime {
const platform = input.platform ?? process.platform
if (platform !== 'darwin') {
if (process.env.NODE_ENV === 'test') {
return new UnsupportedPlatformTestRuntime(input.projectDir)
}
throw unsupportedPlatformError()
}
const browserosRoot = input.browserosRoot ?? getBrowserosDir()
if (input.resourcesDir) {
migrateLegacyOpenClawDirSync(browserosRoot)
}
const limactlPath = input.resourcesDir
? resolveBundledLimactl(input.resourcesDir)
: 'limactl'
const limaHome = getLimaHomeDir(browserosRoot)
const vm = new VmRuntime({
limactlPath,
limaHome,
templatePath: input.resourcesDir
? resolveBundledLimaTemplate(input.resourcesDir)
: undefined,
browserosRoot,
})
const shell = new ContainerCli({ limactlPath, limaHome, vmName: VM_NAME })
const loader = new DeferredImageLoader(shell, browserosRoot)
return new ContainerRuntime({
vm,
shell,
loader,
projectDir: input.projectDir,
})
}
export async function migrateLegacyOpenClawDir(
browserosRoot = getBrowserosDir(),
): Promise<void> {
migrateLegacyOpenClawDirSync(browserosRoot)
}
function migrateLegacyOpenClawDirSync(browserosRoot = getBrowserosDir()): void {
const legacyDir = join(browserosRoot, 'openclaw')
const nextDir = join(browserosRoot, 'vm', 'openclaw')
if (!existsSync(legacyDir)) return
if (existsSync(nextDir)) {
logger.warn('OpenClaw legacy and VM state directories both exist', {
legacyDir,
nextDir,
})
return
}
mkdirSync(dirname(nextDir), { recursive: true })
cpSync(legacyDir, nextDir, { recursive: true })
logger.info(VM_TELEMETRY_EVENTS.migrationOpenClawMoved, {
from: legacyDir,
to: nextDir,
})
}
class DeferredImageLoader {
constructor(
private readonly shell: ContainerCli,
private readonly browserosRoot: string,
) {}
async ensureImageLoaded(ref: string, onLog?: (msg: string) => void) {
const manifest = await readCachedManifest(this.browserosRoot)
const loader = new ImageLoader(
this.shell,
manifest,
detectArch(),
this.browserosRoot,
)
await loader.ensureImageLoaded(ref, onLog)
}
}
class UnsupportedPlatformTestRuntime extends ContainerRuntime {
constructor(projectDir: string) {
super({
vm: {} as VmRuntime,
shell: {} as ContainerCli,
loader: { ensureImageLoaded: rejectUnsupportedPlatform },
projectDir,
})
}
override async ensureReady(): Promise<void> {
throw unsupportedPlatformError()
}
override async isPodmanAvailable(): Promise<boolean> {
return false
}
override async getMachineStatus(): Promise<{
initialized: boolean
running: boolean
}> {
return { initialized: false, running: false }
}
override async pullImage(): Promise<void> {
throw unsupportedPlatformError()
}
override async startGateway(): Promise<void> {
throw unsupportedPlatformError()
}
override async stopGateway(): Promise<void> {}
override async restartGateway(): Promise<void> {
throw unsupportedPlatformError()
}
override async getGatewayLogs(): Promise<string[]> {
return []
}
override async isHealthy(): Promise<boolean> {
return false
}
override async isReady(): Promise<boolean> {
return false
}
override async waitForReady(): Promise<boolean> {
return false
}
override async stopVm(): Promise<void> {}
override async execInContainer(): Promise<number> {
throw unsupportedPlatformError()
}
override async runGatewaySetupCommand(): Promise<number> {
throw unsupportedPlatformError()
}
override tailGatewayLogs(): () => void {
return () => {}
}
}
async function rejectUnsupportedPlatform(): Promise<never> {
throw unsupportedPlatformError()
}
function unsupportedPlatformError(): Error {
return new Error(UNSUPPORTED_PLATFORM_MESSAGE)
}

View File

@@ -2,285 +2,191 @@
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Compose-level abstraction over PodmanRuntime.
* Manages a single compose project for the OpenClaw gateway container.
*/
import { copyFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
OPENCLAW_COMPOSE_PROJECT_NAME,
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_GATEWAY_CONTAINER_PORT,
} from '@browseros/shared/constants/openclaw'
import type { ContainerCli, ContainerSpec, LogFn } from '../../../lib/container'
import { logger } from '../../../lib/logger'
import {
GUEST_VM_STATE,
hostPathToGuest,
type VmRuntime,
} from '../../../lib/vm'
import type { LogFn, PodmanRuntime } from './podman-runtime'
const GATEWAY_CONTAINER_HOME = '/home/node'
const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
const GUEST_OPENCLAW_HOME = `${GUEST_VM_STATE}/openclaw`
export type GatewayContainerSpec = {
image: string
hostPort: number
hostHome: string
envFilePath: string
gatewayToken?: string
timezone: string
}
export interface ContainerRuntimeConfig {
vm: VmRuntime
shell: ContainerCli
loader: { ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void> }
projectDir: string
}
const COMPOSE_FILE_NAME = 'docker-compose.yml'
const ENV_FILE_NAME = '.env'
export class ContainerRuntime {
private readonly vm: VmRuntime
private readonly shell: ContainerCli
private readonly loader: {
ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void>
}
private readonly projectDir: string
constructor(config: ContainerRuntimeConfig) {
this.vm = config.vm
this.shell = config.shell
this.loader = config.loader
this.projectDir = config.projectDir
}
constructor(
private podman: PodmanRuntime,
private projectDir: string,
) {}
async ensureReady(onLog?: LogFn): Promise<void> {
logger.info('Ensuring BrowserOS VM runtime readiness')
await this.vm.ensureReady(onLog)
await this.vm.getDefaultGateway()
logger.info('Ensuring Podman runtime readiness')
return this.podman.ensureReady(onLog)
}
async isPodmanAvailable(): Promise<boolean> {
return true
return this.podman.isPodmanAvailable()
}
async getMachineStatus(): Promise<{
initialized: boolean
running: boolean
}> {
const running = await this.vm.isReady()
return { initialized: running, running }
return this.podman.getMachineStatus()
}
async pullImage(image: string, onLog?: LogFn): Promise<void> {
await this.loader.ensureImageLoaded(image, onLog)
async composeUp(onLog?: LogFn): Promise<void> {
const code = await this.compose(['up', '-d'], onLog)
if (code !== 0) throw new Error(`compose up failed with code ${code}`)
}
async startGateway(
input: GatewayContainerSpec,
onLog?: LogFn,
): Promise<void> {
await this.removeGatewayContainer(onLog)
await this.loader.ensureImageLoaded(input.image, onLog)
const container = await this.buildGatewayContainerSpec(input)
await this.shell.createContainer(container, onLog)
await this.shell.startContainer(container.name)
async composeDown(onLog?: LogFn): Promise<void> {
const code = await this.compose(['down'], onLog)
if (code !== 0) throw new Error(`compose down failed with code ${code}`)
}
async stopGateway(onLog?: LogFn): Promise<void> {
await this.removeGatewayContainer(onLog)
async composeStop(onLog?: LogFn): Promise<void> {
const code = await this.compose(['stop'], onLog)
if (code !== 0) throw new Error(`compose stop failed with code ${code}`)
}
async restartGateway(
input: GatewayContainerSpec,
onLog?: LogFn,
): Promise<void> {
await this.startGateway(input, onLog)
async composeRestart(onLog?: LogFn): Promise<void> {
const code = await this.compose(['restart'], onLog)
if (code !== 0) throw new Error(`compose restart failed with code ${code}`)
}
async getGatewayLogs(tail = 50): Promise<string[]> {
async composePull(onLog?: LogFn): Promise<void> {
const code = await this.compose(['pull', '--quiet'], onLog)
if (code !== 0) throw new Error(`compose pull failed with code ${code}`)
}
async composeLogs(tail = 50): Promise<string[]> {
const lines: string[] = []
await this.shell.runCommand(
['logs', '-n', String(tail), OPENCLAW_GATEWAY_CONTAINER_NAME],
(line) => lines.push(line),
await this.compose(['logs', '--no-color', '--tail', String(tail)], (line) =>
lines.push(line),
)
return lines
}
async isHealthy(hostPort: number): Promise<boolean> {
async isHealthy(port: number): Promise<boolean> {
try {
const res = await fetch(`http://127.0.0.1:${hostPort}/healthz`)
const res = await fetch(`http://127.0.0.1:${port}/healthz`)
return res.ok
} catch {
return false
}
}
async isReady(hostPort: number): Promise<boolean> {
async isReady(port: number): Promise<boolean> {
try {
const res = await fetch(`http://127.0.0.1:${hostPort}/readyz`)
const res = await fetch(`http://127.0.0.1:${port}/readyz`)
return res.ok
} catch {
return false
}
}
async waitForReady(hostPort: number, timeoutMs = 30_000): Promise<boolean> {
logger.info('Waiting for OpenClaw gateway readiness', {
hostPort,
timeoutMs,
})
async waitForReady(port: number, timeoutMs = 30_000): Promise<boolean> {
logger.info('Waiting for OpenClaw gateway readiness', { port, timeoutMs })
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (await this.isReady(hostPort)) return true
if (await this.isReady(port)) {
logger.info('OpenClaw gateway became ready', {
port,
waitMs: Date.now() - start,
})
return true
}
await Bun.sleep(1000)
}
logger.error('Timed out waiting for OpenClaw gateway readiness', {
hostPort,
port,
timeoutMs,
})
return false
}
async stopVm(): Promise<void> {
await this.vm.stopVm()
async copyComposeFile(sourceTemplatePath: string): Promise<void> {
await copyFile(sourceTemplatePath, join(this.projectDir, COMPOSE_FILE_NAME))
}
async writeEnvFile(content: string): Promise<void> {
await writeFile(join(this.projectDir, ENV_FILE_NAME), content, {
mode: 0o600,
})
}
/**
* Stops the Podman machine only if no non-BrowserOS containers are running.
* Prevents killing the user's own Podman workloads.
*/
async stopMachineIfSafe(): Promise<void> {
const status = await this.podman.getMachineStatus()
if (!status.running) return
try {
const containers = await this.podman.listRunningContainers()
const allOurs = containers.every((name) =>
name.startsWith(OPENCLAW_COMPOSE_PROJECT_NAME),
)
if (containers.length === 0 || allOurs) {
await this.podman.stopMachine()
}
} catch {
// Best effort — don't stop machine if we can't check
}
}
async execInContainer(command: string[], onLog?: LogFn): Promise<number> {
return this.shell.exec(OPENCLAW_GATEWAY_CONTAINER_NAME, command, onLog)
}
async runGatewaySetupCommand(
command: string[],
spec: GatewayContainerSpec,
onLog?: LogFn,
): Promise<number> {
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
await this.shell.removeContainer(setupContainerName, { force: true }, onLog)
await this.loader.ensureImageLoaded(spec.image, onLog)
const setupArgs = command[0] === 'node' ? command.slice(1) : command
const createResult = await this.shell.runCommand(
[
'create',
'--name',
setupContainerName,
...(await this.buildGatewayRunArgs(spec)),
spec.image,
'node',
...setupArgs,
],
onLog,
return this.podman.runCommand(
['exec', OPENCLAW_GATEWAY_CONTAINER_NAME, ...command],
{
onOutput: onLog,
},
)
if (createResult.exitCode !== 0) {
await this.shell.removeContainer(
setupContainerName,
{ force: true },
onLog,
)
return createResult.exitCode
}
try {
const startResult = await this.shell.runCommand(
['start', '-a', setupContainerName],
onLog,
)
return startResult.exitCode
} finally {
await this.shell.removeContainer(
setupContainerName,
{ force: true },
onLog,
)
}
}
tailGatewayLogs(onLine: LogFn): () => void {
return this.shell.tailLogs(OPENCLAW_GATEWAY_CONTAINER_NAME, onLine)
}
private async removeGatewayContainer(onLog?: LogFn): Promise<void> {
await this.shell.removeContainer(
return this.podman.tailContainerLogs(
OPENCLAW_GATEWAY_CONTAINER_NAME,
{ force: true },
onLog,
onLine,
)
}
private async buildGatewayContainerSpec(
input: GatewayContainerSpec,
): Promise<ContainerSpec> {
return {
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
image: input.image,
restart: 'unless-stopped',
ports: [
{
hostIp: '127.0.0.1',
hostPort: input.hostPort,
containerPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
},
],
envFile: this.translateHostPath(input.envFilePath, input.hostHome),
env: this.buildGatewayEnv(input),
mounts: [{ source: GUEST_OPENCLAW_HOME, target: GATEWAY_CONTAINER_HOME }],
addHosts: [await this.hostContainersInternalEntry()],
health: {
cmd: `curl -sf http://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}/healthz`,
interval: '30s',
timeout: '10s',
retries: 3,
private async compose(args: string[], onLog?: LogFn): Promise<number> {
const lines: string[] = []
const command = ['podman', 'compose', ...args].join(' ')
logger.info('Running OpenClaw compose command', {
command,
})
const code = await this.podman.runCommand(['compose', ...args], {
cwd: this.projectDir,
env: { COMPOSE_PROJECT_NAME: OPENCLAW_COMPOSE_PROJECT_NAME },
onOutput: (line) => {
lines.push(line)
onLog?.(line)
},
command: [
'node',
'dist/index.js',
'gateway',
'--bind',
'lan',
'--port',
String(OPENCLAW_GATEWAY_CONTAINER_PORT),
'--allow-unconfigured',
],
}
}
})
private async buildGatewayRunArgs(
input: GatewayContainerSpec,
): Promise<string[]> {
const args = [
'--env-file',
this.translateHostPath(input.envFilePath, input.hostHome),
'-v',
`${GUEST_OPENCLAW_HOME}:${GATEWAY_CONTAINER_HOME}`,
]
for (const [key, value] of Object.entries(this.buildGatewayEnv(input))) {
args.push('-e', `${key}=${value}`)
if (code !== 0) {
logger.error('OpenClaw compose command failed', {
command,
exitCode: code,
output: lines,
})
} else {
logger.info('OpenClaw compose command succeeded', {
command,
})
}
args.push('--add-host', await this.hostContainersInternalEntry())
return args
}
private async hostContainersInternalEntry(): Promise<string> {
return `host.containers.internal:${await this.vm.getDefaultGateway()}`
}
private buildGatewayEnv(input: GatewayContainerSpec): Record<string, string> {
return {
HOME: GATEWAY_CONTAINER_HOME,
OPENCLAW_HOME: GATEWAY_CONTAINER_HOME,
OPENCLAW_STATE_DIR: GATEWAY_STATE_DIR,
OPENCLAW_NO_RESPAWN: '1',
NODE_COMPILE_CACHE: '/var/tmp/openclaw-compile-cache',
NODE_ENV: 'production',
TZ: input.timezone,
...(input.gatewayToken
? { OPENCLAW_GATEWAY_TOKEN: input.gatewayToken }
: {}),
}
}
private translateHostPath(path: string, openclawHostDir: string): string {
if (path === openclawHostDir) return GUEST_OPENCLAW_HOME
if (path.startsWith(`${openclawHostDir}/`)) {
return `${GUEST_OPENCLAW_HOME}${path.slice(openclawHostDir.length)}`
}
return hostPathToGuest(path)
return code
}
}

View File

@@ -27,10 +27,3 @@ export class OpenClawProtectedAgentError extends Error {
this.name = 'OpenClawProtectedAgentError'
}
}
export class OpenClawSessionNotFoundError extends Error {
constructor(public readonly sessionKey: string) {
super(`OpenClaw session not found: ${sessionKey}`)
this.name = 'OpenClawSessionNotFoundError'
}
}

View File

@@ -0,0 +1,754 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* WebSocket client for the OpenClaw Gateway protocol.
* Handles handshake (challenge → connect → hello-ok) with Ed25519 device
* identity signing, JSON-RPC over WS, and auto-reconnect.
* Used for agent CRUD and health — chat uses HTTP.
*/
import crypto from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
import { logger } from '../../../lib/logger'
const RPC_TIMEOUT_MS = 15_000
const SCOPES = [
'operator.read',
'operator.write',
'operator.admin',
'operator.approvals',
'operator.pairing',
]
interface DeviceIdentity {
deviceId: string
publicKeyPem: string
privateKeyPem: string
}
interface PendingRequest {
resolve: (value: unknown) => void
reject: (reason: Error) => void
timer: ReturnType<typeof setTimeout>
}
interface WsFrame {
type: 'req' | 'res' | 'event'
id?: string
method?: string
params?: Record<string, unknown>
ok?: boolean
payload?: Record<string, unknown>
error?: { message: string; code?: string }
event?: string
}
export type GatewayClientConnectionState =
| 'idle'
| 'connecting'
| 'connected'
| 'closed'
| 'failed'
export interface GatewayHandshakeError {
code?: string
message: string
}
export interface OpenClawStreamEvent {
type:
| 'text-delta'
| 'thinking'
| 'tool-start'
| 'tool-end'
| 'tool-output'
| 'lifecycle'
| 'done'
| 'error'
data: Record<string, unknown>
}
export interface GatewayAgentEntry {
agentId: string
name: string
workspace: string
model?: string
}
// ── Device Identity Helpers ─────────────────────────────────────────
function rawPublicKeyFromPem(pem: string): Buffer {
const der = Buffer.from(
pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''),
'base64',
)
return der.subarray(12)
}
function signChallenge(
device: DeviceIdentity,
nonce: string,
token: string,
): { signature: string; signedAt: number; publicKey: string } {
const signedAt = Date.now()
const payload = `v3|${device.deviceId}|cli|cli|operator|${SCOPES.join(',')}|${signedAt}|${token}|${nonce}|${process.platform}|`
const privateKey = crypto.createPrivateKey(device.privateKeyPem)
const sig = crypto.sign(null, Buffer.from(payload, 'utf-8'), privateKey)
return {
signature: sig.toString('base64url'),
signedAt,
publicKey: rawPublicKeyFromPem(device.publicKeyPem).toString('base64url'),
}
}
/**
* Generates a client Ed25519 identity and pre-seeds it into the gateway's
* paired devices file so the gateway trusts it on next boot.
* Must be called before compose up (or requires a restart after).
*/
export function ensureClientIdentity(openclawDir: string): DeviceIdentity {
const identityPath = join(openclawDir, 'client-identity.json')
try {
return JSON.parse(readFileSync(identityPath, 'utf-8'))
} catch {
// Generate new identity
}
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
const publicKeyPem = publicKey
.export({ type: 'spki', format: 'pem' })
.toString()
const privateKeyPem = privateKey
.export({ type: 'pkcs8', format: 'pem' })
.toString()
const rawPub = rawPublicKeyFromPem(publicKeyPem)
const deviceId = crypto.createHash('sha256').update(rawPub).digest('hex')
const identity: DeviceIdentity = { deviceId, publicKeyPem, privateKeyPem }
writeFileSync(identityPath, JSON.stringify(identity, null, 2), {
mode: 0o600,
})
seedPairedDevice(openclawDir, identity)
logger.info('Generated client device identity and pre-seeded pairing')
return identity
}
function seedPairedDevice(openclawDir: string, identity: DeviceIdentity): void {
const devicesDir = join(openclawDir, 'devices')
mkdirSync(devicesDir, { recursive: true })
const pairedPath = join(devicesDir, 'paired.json')
let paired: Record<string, unknown> = {}
try {
paired = JSON.parse(readFileSync(pairedPath, 'utf-8'))
} catch {
// First time
}
const rawPub = rawPublicKeyFromPem(identity.publicKeyPem)
paired[identity.deviceId] = {
deviceId: identity.deviceId,
publicKey: rawPub.toString('base64url'),
platform: process.platform,
clientId: 'cli',
clientMode: 'cli',
role: 'operator',
roles: ['operator'],
scopes: SCOPES,
pairedAt: Date.now(),
label: 'browseros-server',
}
writeFileSync(pairedPath, JSON.stringify(paired, null, 2), { mode: 0o600 })
}
// ── Gateway Client ──────────────────────────────────────────────────
export class GatewayClient {
private ws: WebSocket | null = null
private _connected = false
private pendingRequests = new Map<string, PendingRequest>()
private device: DeviceIdentity | null = null
private connectionState: GatewayClientConnectionState = 'idle'
private lastHandshakeError: GatewayHandshakeError | null = null
constructor(
private readonly port: number,
private readonly token: string,
private readonly openclawDir: string,
private readonly version = '1.0.0',
) {
try {
const identityPath = join(this.openclawDir, 'client-identity.json')
this.device = JSON.parse(readFileSync(identityPath, 'utf-8'))
} catch {
logger.warn('Client device identity not found, WS auth may fail')
}
}
get isConnected(): boolean {
return this._connected
}
get state(): GatewayClientConnectionState {
return this.connectionState
}
get lastError(): GatewayHandshakeError | null {
return this.lastHandshakeError
}
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
this.connectionState = 'connecting'
this.lastHandshakeError = null
logger.info('Connecting to OpenClaw Gateway WS', {
port: this.port,
hasDeviceIdentity: !!this.device,
})
this.ws = new WebSocket(`ws://127.0.0.1:${this.port}`, {
headers: { Origin: `http://127.0.0.1:${this.port}` },
} as unknown as string[])
let handshakeComplete = false
let connectReqId: string | null = null
this.ws.onmessage = (event) => {
const frame = GatewayClient.parseFrame(event.data)
if (!frame) return
if (!handshakeComplete) {
if (frame.type === 'event' && frame.event === 'connect.challenge') {
const nonce = (frame.payload as Record<string, unknown>)
?.nonce as string
logger.info('Received OpenClaw Gateway challenge', {
hasNonce: !!nonce,
hasDeviceIdentity: !!this.device,
})
connectReqId = globalThis.crypto.randomUUID()
const params: Record<string, unknown> = {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'cli',
version: this.version,
platform: process.platform,
mode: 'cli',
},
role: 'operator',
scopes: SCOPES,
caps: [],
commands: [],
permissions: {},
auth: { token: this.token },
locale: 'en-US',
userAgent: `browseros-server/${this.version}`,
}
if (this.device && nonce) {
const signed = signChallenge(this.device, nonce, this.token)
params.device = {
id: this.device.deviceId,
publicKey: signed.publicKey,
signature: signed.signature,
signedAt: signed.signedAt,
nonce,
}
}
this.ws?.send(
JSON.stringify({
type: 'req',
id: connectReqId,
method: 'connect',
params,
}),
)
return
}
if (frame.type === 'res' && frame.id === connectReqId) {
if (frame.ok) {
handshakeComplete = true
this._connected = true
this.connectionState = 'connected'
logger.info('Gateway WS connected')
resolve()
} else {
const msg = frame.error?.message ?? 'Handshake failed'
this.connectionState = 'failed'
this.lastHandshakeError = {
message: msg,
code: frame.error?.code,
}
logger.error('Gateway WS handshake rejected', {
error: msg,
code: frame.error?.code,
})
reject(new Error(msg))
}
return
}
return
}
this.resolvePendingRequest(frame)
}
this.ws.onerror = (err) => {
logger.error('Gateway WS socket error', {
error: err instanceof Error ? err.message : 'unknown',
handshakeComplete,
})
if (!handshakeComplete) {
this.connectionState = 'failed'
reject(
new Error(
`WS connection error: ${err instanceof Error ? err.message : 'unknown'}`,
),
)
}
}
this.ws.onclose = () => {
this._connected = false
this.connectionState = 'closed'
this.rejectAllPending('WebSocket closed')
if (handshakeComplete) {
logger.info('Gateway WS disconnected')
}
this.ws = null
}
})
}
disconnect(): void {
this._connected = false
this.connectionState = 'closed'
this.rejectAllPending('Client disconnecting')
if (this.ws) {
this.ws.onclose = null
this.ws.close()
this.ws = null
}
}
// ── RPC ──────────────────────────────────────────────────────────────
async rpc<T = Record<string, unknown>>(
method: string,
params: Record<string, unknown> = {},
): Promise<T> {
if (!this._connected || !this.ws) {
throw new Error('Gateway WS not connected')
}
const id = globalThis.crypto.randomUUID()
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id)
reject(new Error(`RPC timeout: ${method}`))
}, RPC_TIMEOUT_MS)
this.pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
timer,
})
this.ws?.send(JSON.stringify({ type: 'req', id, method, params }))
})
}
// ── Agent Methods ────────────────────────────────────────────────────
async listAgents(): Promise<GatewayAgentEntry[]> {
const result = await this.rpc<{
agents: Array<{
id: string
name?: string
workspace: string
model?: string
}>
}>('agents.list')
return (result.agents ?? []).map((a) => ({
agentId: a.id,
name: a.name ?? a.id,
workspace: a.workspace,
model: a.model,
}))
}
async createAgent(input: {
name: string
workspace: string
model?: string
}): Promise<GatewayAgentEntry> {
const result = await this.rpc<{
agentId?: string
id?: string
name?: string
workspace?: string
model?: string
}>('agents.create', input)
return {
agentId: result.agentId ?? result.id ?? input.name,
name: result.name ?? input.name,
workspace: result.workspace ?? input.workspace,
model: result.model ?? input.model,
}
}
async deleteAgent(agentId: string): Promise<void> {
await this.rpc('agents.delete', { id: agentId })
}
// ── Health ───────────────────────────────────────────────────────────
async getHealth(): Promise<Record<string, unknown>> {
return this.rpc('health')
}
// ── Chat Stream ─────────────────────────────────────────────────────
chatStream(
agentId: string,
sessionKey: string,
message: string,
): ReadableStream<OpenClawStreamEvent> {
if (!this._connected) {
throw new Error('Gateway WS not connected')
}
const fullSessionKey = `agent:${agentId}:browseros-${sessionKey}`
const idempotencyKey = globalThis.crypto.randomUUID()
const streamClient = new GatewayClient(
this.port,
this.token,
this.openclawDir,
this.version,
)
return new ReadableStream<OpenClawStreamEvent>({
start: async (controller) => {
try {
await streamClient.connect()
} catch (error) {
controller.enqueue({
type: 'error',
data: {
message:
error instanceof Error
? error.message
: 'Gateway WS not connected',
},
})
controller.close()
return
}
const ws = streamClient.ws
if (!ws) {
controller.enqueue({
type: 'error',
data: { message: 'Gateway WS not connected' },
})
controller.close()
return
}
const subscribeId = globalThis.crypto.randomUUID()
const agentReqId = globalThis.crypto.randomUUID()
let finished = false
const finish = (event?: OpenClawStreamEvent) => {
if (finished) return
finished = true
if (event) controller.enqueue(event)
controller.close()
streamClient.disconnect()
}
ws.onmessage = (event) => {
const frame = GatewayClient.parseFrame(event.data)
if (!frame) return
if (
this.handleChatStreamControlFrame(
frame,
subscribeId,
agentReqId,
finish,
)
) {
return
}
this.handleChatStreamEventFrame(frame, controller, finish)
}
ws.onclose = () => {
if (finished) return
finish({
type: 'error',
data: { message: 'Gateway WS disconnected' },
})
}
ws.onerror = () => {
if (finished) return
finish({
type: 'error',
data: { message: 'Gateway WS connection error' },
})
}
ws.send(
JSON.stringify({
type: 'req',
id: subscribeId,
method: 'sessions.subscribe',
params: { sessionKey: fullSessionKey },
}),
)
ws.send(
JSON.stringify({
type: 'req',
id: agentReqId,
method: 'agent',
params: {
message,
sessionKey: fullSessionKey,
idempotencyKey,
},
}),
)
},
cancel: () => {
if (streamClient.ws?.readyState === WebSocket.OPEN) {
streamClient.ws.send(
JSON.stringify({
type: 'req',
id: globalThis.crypto.randomUUID(),
method: 'sessions.abort',
params: { sessionKey: fullSessionKey },
}),
)
}
streamClient.disconnect()
},
})
}
// ── Helpers ──────────────────────────────────────────────────────────
static agentWorkspace(name: string): string {
return name === 'main'
? `${OPENCLAW_CONTAINER_HOME}/workspace`
: `${OPENCLAW_CONTAINER_HOME}/workspace-${name}`
}
private static parseFrame(data: unknown): WsFrame | null {
try {
return JSON.parse(
typeof data === 'string'
? data
: new TextDecoder().decode(data as ArrayBuffer),
) as WsFrame
} catch {
return null
}
}
private rejectAllPending(reason: string): void {
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer)
pending.reject(new Error(reason))
this.pendingRequests.delete(id)
}
}
private resolvePendingRequest(frame: WsFrame): void {
if (frame.type !== 'res' || !frame.id) return
const pending = this.pendingRequests.get(frame.id)
if (!pending) return
this.pendingRequests.delete(frame.id)
clearTimeout(pending.timer)
if (frame.ok) {
pending.resolve(frame.payload)
} else {
pending.reject(new Error(frame.error?.message ?? 'RPC error'))
}
}
private handleChatStreamControlFrame(
frame: WsFrame,
subscribeId: string,
agentReqId: string,
finish: (event?: OpenClawStreamEvent) => void,
): boolean {
if (frame.type !== 'res' || !frame.id) return false
if (frame.id !== subscribeId && frame.id !== agentReqId) return false
if (!frame.ok) {
finish({
type: 'error',
data: {
message: frame.error?.message ?? 'RPC error',
code: frame.error?.code,
},
})
}
return true
}
private handleChatStreamEventFrame(
frame: WsFrame,
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
finish: (event?: OpenClawStreamEvent) => void,
): void {
if (frame.type !== 'event' || !frame.event || !frame.payload) return
switch (frame.event) {
case 'agent':
this.handleAgentStreamEvent(frame.payload, controller)
return
case 'session.tool':
this.handleSessionToolStreamEvent(frame.payload, controller)
return
case 'session.message':
this.handleSessionMessageStreamEvent(frame.payload, controller)
return
case 'chat':
this.handleChatCompletionEvent(frame.payload, finish)
return
default:
return
}
}
private handleAgentStreamEvent(
payload: Record<string, unknown>,
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
): void {
const streamType = payload.stream as string | undefined
const data = payload.data as Record<string, unknown> | undefined
if (streamType === 'assistant' && data?.delta) {
controller.enqueue({
type: 'text-delta',
data: { text: data.delta },
})
return
}
if (streamType === 'item' && data) {
const phase = data.phase as string | undefined
if (phase === 'start') {
controller.enqueue({
type: 'tool-start',
data: {
toolCallId: data.toolCallId ?? data.id,
toolName: data.name ?? data.title,
kind: data.kind,
},
})
return
}
if (phase === 'end') {
controller.enqueue({
type: 'tool-end',
data: {
toolCallId: data.toolCallId ?? data.id,
status: data.status,
durationMs: data.durationMs,
},
})
return
}
}
if (streamType === 'lifecycle') {
controller.enqueue({
type: 'lifecycle',
data: { phase: data?.phase ?? payload.phase },
})
}
}
private handleSessionToolStreamEvent(
payload: Record<string, unknown>,
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
): void {
const toolData = (payload.data as Record<string, unknown>) ?? payload
const phase = (toolData.phase as string) ?? (payload.phase as string)
if (phase !== 'result') return
controller.enqueue({
type: 'tool-output',
data: {
toolCallId: toolData.toolCallId,
isError: toolData.isError ?? false,
meta: toolData.meta,
},
})
}
private handleSessionMessageStreamEvent(
payload: Record<string, unknown>,
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
): void {
const message = payload.message as Record<string, unknown> | undefined
if (message?.role !== 'assistant') return
const content = message.content as
| Array<Record<string, unknown>>
| undefined
if (!content) return
for (const block of content) {
if (block.type !== 'thinking') continue
const text =
(block.thinking as string) ??
(block.content as string) ??
(block.text as string) ??
''
if (!text) continue
controller.enqueue({
type: 'thinking',
data: { text },
})
}
}
private handleChatCompletionEvent(
payload: Record<string, unknown>,
finish: (event?: OpenClawStreamEvent) => void,
): void {
if ((payload.state as string | undefined) !== 'final') return
finish({
type: 'done',
data: { text: (payload.text as string) ?? '' },
})
}
}

View File

@@ -1,407 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
type LogFn = (line: string) => void
interface ContainerExecutor {
execInContainer(command: string[], onLog?: LogFn): Promise<number>
}
export interface OpenClawConfigBatchEntry {
path: string
value: unknown
}
interface RawAgentRecord {
id: string
name?: string
workspace: string
model?: string
}
export interface OpenClawAgentRecord {
agentId: string
name: string
workspace: string
model?: string
}
export class OpenClawCliClient {
constructor(private readonly executor: ContainerExecutor) {}
async runOnboard(
input: {
acceptRisk?: boolean
authChoice?: string
customBaseUrl?: string
customCompatibility?: 'anthropic' | 'openai-completions'
customModelId?: string
customProviderId?: string
gatewayAuth?: 'none' | 'password' | 'token'
gatewayBind?: 'auto' | 'custom' | 'lan' | 'loopback' | 'tailnet'
gatewayPort?: number
gatewayToken?: string
gatewayTokenRefEnv?: string
installDaemon?: boolean
mode?: 'local' | 'remote'
nonInteractive?: boolean
reset?: boolean
resetScope?: 'config' | 'config+creds+sessions' | 'full'
secretInputMode?: 'plain' | 'ref'
skipHealth?: boolean
workspace?: string
} = {},
): Promise<void> {
const args = ['onboard']
if (input.nonInteractive) {
args.push('--non-interactive')
}
if (input.mode) {
args.push('--mode', input.mode)
}
if (input.workspace) {
args.push('--workspace', input.workspace)
}
if (input.reset) {
args.push('--reset')
}
if (input.resetScope) {
args.push('--reset-scope', input.resetScope)
}
if (input.authChoice) {
args.push('--auth-choice', input.authChoice)
}
if (input.secretInputMode) {
args.push('--secret-input-mode', input.secretInputMode)
}
if (input.customBaseUrl) {
args.push('--custom-base-url', input.customBaseUrl)
}
if (input.customModelId) {
args.push('--custom-model-id', input.customModelId)
}
if (input.customProviderId) {
args.push('--custom-provider-id', input.customProviderId)
}
if (input.customCompatibility) {
args.push('--custom-compatibility', input.customCompatibility)
}
if (input.gatewayAuth) {
args.push('--gateway-auth', input.gatewayAuth)
}
if (input.gatewayToken) {
args.push('--gateway-token', input.gatewayToken)
}
if (input.gatewayTokenRefEnv) {
args.push('--gateway-token-ref-env', input.gatewayTokenRefEnv)
}
if (input.gatewayPort) {
args.push('--gateway-port', String(input.gatewayPort))
}
if (input.gatewayBind) {
args.push('--gateway-bind', input.gatewayBind)
}
if (input.installDaemon === true) {
args.push('--install-daemon')
} else if (input.installDaemon === false) {
args.push('--no-install-daemon')
}
if (input.skipHealth) {
args.push('--skip-health')
}
if (input.acceptRisk) {
args.push('--accept-risk')
}
await this.runCommand(args)
}
async setConfig(path: string, value: unknown): Promise<void> {
await this.runCommand(['config', 'set', path, formatConfigValue(value)])
}
async setConfigBatch(entries: OpenClawConfigBatchEntry[]): Promise<void> {
await this.runCommand([
'config',
'set',
'--batch-json',
JSON.stringify(entries),
])
}
async getConfig(path: string): Promise<unknown> {
const output = await this.runCommand(['config', 'get', path])
return parseConfigValue(output)
}
async validateConfig(): Promise<unknown> {
const output = await this.runCommand(['config', 'validate', '--json'])
return parseConfigValue(output)
}
async setDefaultModel(model: string): Promise<void> {
await this.runCommand(['models', 'set', model])
}
async listAgents(): Promise<OpenClawAgentRecord[]> {
const records = await this.runAgentListCommand()
const agents = Array.isArray(records) ? records : (records.agents ?? [])
return agents.map((record) => ({
agentId: record.id,
name: record.name ?? record.id,
workspace: record.workspace,
model: record.model,
}))
}
async createAgent(input: {
name: string
model?: string
}): Promise<OpenClawAgentRecord> {
const workspace = this.agentWorkspace(input.name)
const args = ['agents', 'add', input.name, '--workspace', workspace]
if (input.model) {
args.push('--model', input.model)
}
args.push('--non-interactive', '--json')
await this.runCommand(args)
const agents = await this.listAgents()
const agent = agents.find((entry) => entry.agentId === input.name)
if (!agent) {
throw new Error(`Created agent ${input.name} was not found in agent list`)
}
return agent
}
async deleteAgent(agentId: string): Promise<void> {
await this.runCommand(['agents', 'delete', agentId, '--force', '--json'])
}
async probe(): Promise<void> {
await this.listAgents()
}
private agentWorkspace(name: string): string {
return name === 'main'
? `${OPENCLAW_CONTAINER_HOME}/workspace`
: `${OPENCLAW_CONTAINER_HOME}/workspace-${name}`
}
private async runCommand(args: string[]): Promise<string> {
const output: string[] = []
const command = ['node', 'dist/index.js', ...args]
const exitCode = await this.executor.execInContainer(command, (line) => {
output.push(line)
})
if (exitCode !== 0) {
const detail = output.join('\n').trim()
throw new Error(
detail || `OpenClaw command failed (${args.slice(0, 2).join(' ')})`,
)
}
return output.join('\n').trim()
}
private async runAgentListCommand(): Promise<
RawAgentRecord[] | { agents?: RawAgentRecord[] }
> {
const output = await this.runCommand(['agents', 'list', '--json'])
return parseAgentListOutput(output)
}
}
function formatConfigValue(value: unknown): string {
if (typeof value === 'string') return value
return JSON.stringify(value)
}
function parseConfigValue(output: string): unknown {
const parsed = selectConfigJson<unknown>(output)
return parsed ?? output
}
function parseAgentListOutput(
output: string,
): RawAgentRecord[] | { agents?: RawAgentRecord[] } {
const parsed = parseFirstMatchingJson<
RawAgentRecord[] | { agents?: RawAgentRecord[] }
>(output, isAgentListPayload)
if (parsed !== null) return parsed
throw new Error(
`Failed to parse OpenClaw JSON output: ${output.slice(0, 200)}`,
)
}
function parseFirstMatchingJson<T>(
output: string,
predicate?: (value: unknown) => boolean,
): T | null {
const candidates = collectJsonCandidates(output)
for (const candidate of candidates) {
const parsed = tryParseJson<T>(candidate)
if (parsed === null) continue
if (predicate && !predicate(parsed)) continue
return parsed
}
return null
}
function selectConfigJson<T>(output: string): T | null {
const candidates = collectJsonCandidates(output)
const parsedCandidates: Array<{ text: string; value: T }> = []
for (const candidate of candidates) {
const parsed = tryParseJson<T>(candidate)
if (parsed === null) continue
if (isStructuredLogPayload(parsed)) continue
parsedCandidates.push({ text: candidate, value: parsed })
}
if (parsedCandidates.length === 0) return null
return parsedCandidates.reduce((best, candidate) =>
candidate.text.length > best.text.length ? candidate : best,
).value
}
function collectJsonCandidates(output: string): string[] {
const candidates = [output.trim()]
for (const line of output.split(/\r?\n/)) {
const trimmed = line.trim()
if (trimmed) candidates.push(trimmed)
}
for (let index = 0; index < output.length; index += 1) {
const char = output[index]
if (char !== '[' && char !== '{') continue
const extracted = extractJsonSubstring(output, index)
if (extracted) {
candidates.push(extracted)
}
}
return candidates
}
function extractJsonSubstring(
output: string,
startIndex: number,
): string | null {
const opening = output[startIndex]
const closing = opening === '{' ? '}' : ']'
const stack: string[] = [closing]
let inString = false
let escaped = false
for (let index = startIndex + 1; index < output.length; index += 1) {
const char = output[index]
if (inString) {
if (escaped) {
escaped = false
continue
}
if (char === '\\') {
escaped = true
continue
}
if (char === '"') {
inString = false
}
continue
}
if (char === '"') {
inString = true
continue
}
if (char === '{') {
stack.push('}')
continue
}
if (char === '[') {
stack.push(']')
continue
}
const expectedClosing = stack[stack.length - 1]
if (char === expectedClosing) {
stack.pop()
if (stack.length === 0) {
return output.slice(startIndex, index + 1)
}
}
}
return null
}
function tryParseJson<T>(value: string): T | null {
const trimmed = value.trim()
if (!trimmed) return null
try {
return JSON.parse(trimmed) as T
} catch {
return null
}
}
function isAgentListPayload(
value: unknown,
): value is RawAgentRecord[] | { agents?: RawAgentRecord[] } {
if (Array.isArray(value)) {
return value.every(isRawAgentRecord)
}
if (!isPlainObject(value)) return false
if (!('agents' in value)) return false
const agents = (value as { agents?: unknown }).agents
return (
agents === undefined ||
(Array.isArray(agents) && agents.every(isRawAgentRecord))
)
}
function isRawAgentRecord(value: unknown): value is RawAgentRecord {
return (
isPlainObject(value) &&
typeof value.id === 'string' &&
typeof value.workspace === 'string' &&
(value.name === undefined || typeof value.name === 'string') &&
(value.model === undefined || typeof value.model === 'string')
)
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isStructuredLogPayload(value: unknown): boolean {
if (!isPlainObject(value)) return false
return (
typeof value.level === 'string' &&
(typeof value.message === 'string' || typeof value.msg === 'string')
)
}

View File

@@ -0,0 +1,279 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Pure functions for building OpenClaw bootstrap configuration.
* Config is write-once at setup — agent CRUD uses WS RPC, not config edits.
*/
import {
OPENCLAW_CONTAINER_HOME,
OPENCLAW_GATEWAY_PORT,
} from '@browseros/shared/constants/openclaw'
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:latest'
export const PROVIDER_ENV_MAP: Record<string, string> = {
anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
google: 'GEMINI_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
moonshot: 'MOONSHOT_API_KEY',
groq: 'GROQ_API_KEY',
mistral: 'MISTRAL_API_KEY',
}
export interface OpenClawProviderInput {
providerType?: string
providerName?: string
baseUrl?: string
modelId?: string
apiKey?: string
}
export interface BootstrapConfigInput {
gatewayPort: number
gatewayToken: string
browserosServerPort?: number
providerType?: string
providerName?: string
baseUrl?: string
modelId?: string
}
export interface EnvFileInput {
image?: string
port?: number
token: string
configDir: string
timezone?: string
providerKeys?: Record<string, string>
}
export interface ResolvedProviderConfig {
model?: string
providerKeys: Record<string, string>
models?: {
mode: 'merge'
providers: Record<string, Record<string, unknown>>
}
}
function hasBuiltinProvider(providerType?: string): providerType is string {
return !!providerType && providerType in PROVIDER_ENV_MAP
}
/**
* OpenRouter's public slugs use dots for version numbers
* (e.g. `anthropic/claude-haiku-4.5`), but openclaw's model registry expects
* dashes (`claude-haiku-4-5`). Passing the dotted form makes openclaw fail
* the registry lookup silently and the agent turn completes with zero
* payloads. Rewrite dots to dashes for openrouter model ids only.
*/
function normalizeBuiltinModelId(
providerType: string,
modelId: string,
): string {
if (providerType !== 'openrouter') return modelId
return modelId.replace(/\./g, '-')
}
export function deriveOpenClawProviderId(providerInput: {
providerType?: string
providerName?: string
baseUrl?: string
}): string {
const source =
providerInput.providerName?.trim() ||
providerInput.baseUrl?.trim() ||
providerInput.providerType?.trim() ||
'custom-provider'
const candidate = source
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
return candidate || 'custom-provider'
}
export function deriveOpenClawApiKeyEnvVar(providerId: string): string {
return `${providerId.toUpperCase().replace(/-/g, '_')}_API_KEY`
}
export function resolveProviderConfig(
input: OpenClawProviderInput,
): ResolvedProviderConfig {
if (!input.providerType) {
return { providerKeys: {} }
}
if (hasBuiltinProvider(input.providerType)) {
const providerKeys: Record<string, string> = {}
if (input.apiKey) {
providerKeys[PROVIDER_ENV_MAP[input.providerType]] = input.apiKey
}
const normalizedModelId = input.modelId
? normalizeBuiltinModelId(input.providerType, input.modelId)
: undefined
return {
providerKeys,
model: normalizedModelId
? `${input.providerType}/${normalizedModelId}`
: undefined,
}
}
if (!input.baseUrl) {
return { providerKeys: {} }
}
const providerId = deriveOpenClawProviderId(input)
const apiKeyEnvVar = deriveOpenClawApiKeyEnvVar(providerId)
const providerKeys: Record<string, string> = {}
if (input.apiKey) {
providerKeys[apiKeyEnvVar] = input.apiKey
}
const providerConfig: Record<string, unknown> = {
baseUrl: input.baseUrl,
apiKey: `\${${apiKeyEnvVar}}`,
api: 'openai-completions',
}
if (input.modelId) {
providerConfig.models = [{ id: input.modelId, name: input.modelId }]
}
return {
providerKeys,
model: input.modelId ? `${providerId}/${input.modelId}` : undefined,
models: {
mode: 'merge',
providers: {
[providerId]: providerConfig,
},
},
}
}
export function buildBootstrapConfig(
input: BootstrapConfigInput,
): Record<string, unknown> {
const serverPort = input.browserosServerPort ?? DEFAULT_PORTS.server
const provider = resolveProviderConfig(input)
const defaults: Record<string, unknown> = {
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
timeoutSeconds: 4200,
thinkingDefault: 'adaptive',
}
if (provider.model) {
defaults.model = { primary: provider.model }
}
const config: Record<string, unknown> = {
gateway: {
mode: 'local',
port: input.gatewayPort,
bind: 'lan',
auth: { mode: 'token', token: input.gatewayToken },
reload: { mode: 'restart' },
controlUi: {
allowInsecureAuth: true,
allowedOrigins: [
`http://127.0.0.1:${input.gatewayPort}`,
`http://localhost:${input.gatewayPort}`,
],
},
http: {
endpoints: {
chatCompletions: { enabled: true },
},
},
},
agents: { defaults },
tools: {
profile: 'full',
web: {
search: { provider: 'duckduckgo', enabled: true },
},
exec: {
host: 'gateway',
security: 'full',
ask: 'off',
},
},
cron: { enabled: true },
hooks: {
internal: {
enabled: true,
entries: {
'boot-md': { enabled: true },
'bootstrap-extra-files': { enabled: true },
'session-memory': { enabled: true },
},
},
},
mcp: {
servers: {
browseros: {
url: `http://host.containers.internal:${serverPort}/mcp`,
transport: 'streamable-http',
},
},
},
approvals: {
exec: { enabled: false },
},
skills: {
install: { nodeManager: 'bun' },
},
}
if (provider.models) {
config.models = provider.models
}
if (process.env.NODE_ENV === 'development') {
config.logging = { level: 'debug', consoleLevel: 'debug' }
}
return config
}
export function buildEnvFile(input: EnvFileInput): string {
const lines: string[] = [
`OPENCLAW_IMAGE=${input.image ?? OPENCLAW_IMAGE}`,
`OPENCLAW_GATEWAY_PORT=${input.port ?? OPENCLAW_GATEWAY_PORT}`,
`OPENCLAW_GATEWAY_TOKEN=${input.token}`,
`OPENCLAW_CONFIG_DIR=${input.configDir}`,
`TZ=${input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone}`,
]
if (input.providerKeys) {
for (const [key, value] of Object.entries(input.providerKeys)) {
lines.push(`${key}=${value}`)
}
}
return `${lines.join('\n')}\n`
}
export function resolveProviderKeys(
input: OpenClawProviderInput,
): Record<string, string> {
return resolveProviderConfig(input).providerKeys
}
export function resolveProviderModel(
input: OpenClawProviderInput,
): string | undefined {
return resolveProviderConfig(input).model
}

View File

@@ -1,73 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { join } from 'node:path'
const STATE_DIR_NAME = '.openclaw'
export function getOpenClawStateDir(openclawDir: string): string {
return join(openclawDir, STATE_DIR_NAME)
}
export function getOpenClawStateConfigPath(openclawDir: string): string {
return join(getOpenClawStateDir(openclawDir), 'openclaw.json')
}
export function getOpenClawStateEnvPath(openclawDir: string): string {
return join(getOpenClawStateDir(openclawDir), '.env')
}
export function getHostWorkspaceDir(
openclawDir: string,
agentName: string,
): string {
return join(
getOpenClawStateDir(openclawDir),
agentName === 'main' ? 'workspace' : `workspace-${agentName}`,
)
}
export function mergeEnvContent(
current: string,
updates: Record<string, string>,
): { changed: boolean; content: string } {
if (Object.keys(updates).length === 0) {
return {
changed: false,
content: normalizeEnvContent(current),
}
}
const lines = current === '' ? [] : current.replace(/\r\n/g, '\n').split('\n')
const nextLines = [...lines]
let changed = false
for (const [key, value] of Object.entries(updates)) {
const replacement = `${key}=${value}`
const index = nextLines.findIndex((line) => line.startsWith(`${key}=`))
if (index === -1) {
nextLines.push(replacement)
changed = true
continue
}
if (nextLines[index] === replacement) {
continue
}
nextLines[index] = replacement
changed = true
}
const content = normalizeEnvContent(nextLines.join('\n'))
return {
changed: changed || content !== normalizeEnvContent(current),
content,
}
}
function normalizeEnvContent(content: string): string {
const trimmed = content.trim()
return trimmed ? `${trimmed}\n` : ''
}

View File

@@ -1,485 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { createParser, type EventSourceMessage } from 'eventsource-parser'
import { OpenClawSessionNotFoundError } from './errors'
import type { OpenClawStreamEvent } from './openclaw-types'
export interface OpenClawChatHistoryMessage {
role: 'user' | 'assistant'
content: string
}
export interface OpenClawChatRequest {
agentId: string
sessionKey: string
message: string
history?: OpenClawChatHistoryMessage[]
signal?: AbortSignal
}
export interface OpenClawSessionHistoryMessage {
role: 'user' | 'assistant' | 'system' | 'tool'
content: string
messageId?: string
messageSeq?: number
timestamp?: number
}
export interface OpenClawSessionHistory {
sessionKey: string
messages: OpenClawSessionHistoryMessage[]
cursor?: string | null
hasMore?: boolean
truncated?: boolean
}
export interface OpenClawSessionHistoryInput {
limit?: number
cursor?: string
signal?: AbortSignal
}
export type OpenClawSessionHistoryEvent =
| { type: 'history'; data: OpenClawSessionHistory }
| {
type: 'message'
data: {
sessionKey: string
message: OpenClawSessionHistoryMessage
messageId?: string
messageSeq: number
}
}
| { type: 'error'; data: { message: string } }
export class OpenClawHttpClient {
constructor(
private readonly hostPort: number,
private readonly getToken: () => Promise<string>,
) {}
async streamChat(
input: OpenClawChatRequest,
): Promise<ReadableStream<OpenClawStreamEvent>> {
const response = await this.fetchChat(input)
const body = response.body
if (!body) {
throw new Error('OpenClaw chat response had no body')
}
return createEventStream(body, input.signal)
}
async getSessionHistory(
sessionKey: string,
input: OpenClawSessionHistoryInput = {},
): Promise<OpenClawSessionHistory> {
const response = await this.fetchSessionHistory(sessionKey, input, {})
return (await response.json()) as OpenClawSessionHistory
}
async streamSessionHistory(
sessionKey: string,
input: OpenClawSessionHistoryInput = {},
): Promise<ReadableStream<OpenClawSessionHistoryEvent>> {
const response = await this.fetchSessionHistory(sessionKey, input, {
Accept: 'text/event-stream',
})
const body = response.body
if (!body) {
throw new Error('OpenClaw session history stream had no body')
}
return createHistoryEventStream(body, input.signal)
}
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}`,
},
},
)
return response.ok
} catch {
return false
}
}
private async fetchChat(input: OpenClawChatRequest): Promise<Response> {
const token = await this.getToken()
const response = await fetch(
`http://127.0.0.1:${this.hostPort}/v1/chat/completions`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: resolveAgentModel(input.agentId),
stream: true,
messages: [
...(input.history ?? []),
{ role: 'user', content: input.message },
],
user: `browseros:${input.agentId}:${input.sessionKey}`,
}),
signal: input.signal,
},
)
if (response.ok) {
return response
}
const detail = await response.text()
throw new Error(
detail || `OpenClaw chat failed with status ${response.status}`,
)
}
private async fetchSessionHistory(
sessionKey: string,
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,
},
signal: input.signal,
},
)
if (response.status === 404) {
throw new OpenClawSessionNotFoundError(sessionKey)
}
if (!response.ok) {
const detail = await response.text()
throw new Error(
detail ||
`OpenClaw session history failed with status ${response.status}`,
)
}
return response
}
}
function buildHistoryPath(
sessionKey: string,
input: OpenClawSessionHistoryInput,
): string {
const qs = new URLSearchParams()
if (input.limit !== undefined) qs.set('limit', String(input.limit))
if (input.cursor !== undefined) qs.set('cursor', input.cursor)
const suffix = qs.toString()
return `/sessions/${encodeURIComponent(sessionKey)}/history${
suffix ? `?${suffix}` : ''
}`
}
function resolveAgentModel(agentId: string): string {
return agentId === 'main' ? 'openclaw' : `openclaw/${agentId}`
}
function createEventStream(
body: ReadableStream<Uint8Array>,
signal?: AbortSignal,
): ReadableStream<OpenClawStreamEvent> {
return new ReadableStream<OpenClawStreamEvent>({
start(controller) {
void pumpChatEvents(body, controller, signal)
},
})
}
async function pumpChatEvents(
body: ReadableStream<Uint8Array>,
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
signal?: AbortSignal,
): Promise<void> {
const reader = body.getReader()
const decoder = new TextDecoder()
let text = ''
let done = false
const parser = createParser({
onEvent(message) {
if (done) return
const nextText = updateAccumulatedText(message, text)
done = handleMessage(message, controller, nextText, done)
if (!done) {
text = nextText
}
},
})
try {
while (true) {
if (signal?.aborted) {
await reader.cancel()
controller.close()
return
}
const { done: streamDone, value } = await reader.read()
if (streamDone) break
parser.feed(decoder.decode(value, { stream: true }))
}
} catch (error) {
if (!done) {
controller.enqueue({
type: 'error',
data: {
message: error instanceof Error ? error.message : String(error),
},
})
controller.close()
}
} finally {
if (!done) {
controller.close()
}
reader.releaseLock()
}
}
function handleMessage(
message: EventSourceMessage,
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
text: string,
done: boolean,
): boolean {
if (message.data === '[DONE]') {
return finishStream(controller, text, done)
}
const chunk = parseChunk(message.data)
if (!chunk) {
controller.enqueue({
type: 'error',
data: { message: 'Failed to parse OpenClaw chat stream chunk' },
})
controller.close()
return true
}
for (const event of mapChunkToEvents(chunk)) {
controller.enqueue(event)
}
return hasFinishReason(chunk) ? finishStream(controller, text, done) : false
}
function updateAccumulatedText(
message: EventSourceMessage,
text: string,
): string {
const chunk = parseChunk(message.data)
if (!chunk) return text
let next = text
for (const choice of readChoices(chunk)) {
const delta = readDeltaText(choice)
if (delta) {
next += delta
}
}
return next
}
function finishStream(
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
text: string,
done: boolean,
): boolean {
if (!done) {
if (!text.trim()) {
controller.enqueue({
type: 'error',
data: {
message: "Agent couldn't generate a response. Please try again.",
},
})
controller.close()
return true
}
controller.enqueue({
type: 'done',
data: { text },
})
controller.close()
}
return true
}
function mapChunkToEvents(
chunk: Record<string, unknown>,
): OpenClawStreamEvent[] {
const events: OpenClawStreamEvent[] = []
for (const choice of readChoices(chunk)) {
const delta = readDeltaText(choice)
if (delta) {
events.push({
type: 'text-delta',
data: { text: delta },
})
}
}
return events
}
function hasFinishReason(chunk: Record<string, unknown>): boolean {
return readChoices(chunk).some((choice) => !!readFinishReason(choice))
}
function readChoices(
chunk: Record<string, unknown>,
): Array<Record<string, unknown>> {
const choices = chunk.choices
return Array.isArray(choices)
? choices.filter(
(choice): choice is Record<string, unknown> =>
!!choice && typeof choice === 'object',
)
: []
}
function readDeltaText(choice: Record<string, unknown>): string {
const delta = choice.delta
if (!delta || typeof delta !== 'object') return ''
const content = (delta as Record<string, unknown>).content
return typeof content === 'string' ? content : ''
}
function readFinishReason(choice: Record<string, unknown>): string | null {
const reason = choice.finish_reason
return typeof reason === 'string' && reason ? reason : null
}
function parseChunk(data: string): Record<string, unknown> | null {
try {
return JSON.parse(data) as Record<string, unknown>
} catch {
return null
}
}
function createHistoryEventStream(
body: ReadableStream<Uint8Array>,
signal?: AbortSignal,
): ReadableStream<OpenClawSessionHistoryEvent> {
return new ReadableStream<OpenClawSessionHistoryEvent>({
start(controller) {
void pumpHistoryEvents(body, controller, signal)
},
})
}
async function pumpHistoryEvents(
body: ReadableStream<Uint8Array>,
controller: ReadableStreamDefaultController<OpenClawSessionHistoryEvent>,
signal?: AbortSignal,
): Promise<void> {
const reader = body.getReader()
const decoder = new TextDecoder()
let closed = false
const close = () => {
if (closed) return
closed = true
controller.close()
}
const parser = createParser({
onEvent(message) {
if (closed) return
const event = toHistoryEvent(message)
if (!event) return
controller.enqueue(event)
if (event.type === 'error') close()
},
})
const onAbort = () => {
void reader.cancel().catch(() => {})
close()
}
signal?.addEventListener('abort', onAbort, { once: true })
try {
while (true) {
if (signal?.aborted) {
await reader.cancel()
close()
return
}
const { done, value } = await reader.read()
if (done) break
parser.feed(decoder.decode(value, { stream: true }))
}
} catch (error) {
if (!closed) {
controller.enqueue({
type: 'error',
data: {
message: error instanceof Error ? error.message : String(error),
},
})
close()
}
} finally {
signal?.removeEventListener('abort', onAbort)
close()
reader.releaseLock()
}
}
function toHistoryEvent(
message: EventSourceMessage,
): OpenClawSessionHistoryEvent | null {
if (!message.event) return null
const payload = parseChunk(message.data)
if (!payload) return null
if (message.event === 'history') {
return {
type: 'history',
data: payload as unknown as OpenClawSessionHistory,
}
}
if (message.event === 'message') {
return {
type: 'message',
data: payload as unknown as {
sessionKey: string
message: OpenClawSessionHistoryMessage
messageId?: string
messageSeq: number
},
}
}
if (message.event === 'error') {
const errMessage =
typeof payload.message === 'string'
? payload.message
: 'OpenClaw session history stream error'
return { type: 'error', data: { message: errMessage } }
}
return null
}

View File

@@ -1,157 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const SUPPORTED_OPENCLAW_PROVIDERS = [
'openrouter',
'openai',
'anthropic',
'moonshot',
] as const
export type SupportedOpenClawProvider =
(typeof SUPPORTED_OPENCLAW_PROVIDERS)[number]
export interface CustomOpenClawProviderConfig {
providerId: string
apiKeyEnvVar: string
config: Record<string, unknown>
}
export interface ResolvedOpenClawProviderConfig {
envValues: Record<string, string>
model?: string
providerType?: SupportedOpenClawProvider
customProvider?: CustomOpenClawProviderConfig
}
const PROVIDER_ENV_VARS: Record<SupportedOpenClawProvider, string> = {
anthropic: 'ANTHROPIC_API_KEY',
moonshot: 'MOONSHOT_API_KEY',
openai: 'OPENAI_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
}
export class UnsupportedOpenClawProviderError extends Error {
constructor(providerType: string) {
super(`Unsupported OpenClaw provider: ${providerType}`)
this.name = 'UnsupportedOpenClawProviderError'
}
}
export function isUnsupportedOpenClawProviderError(
error: unknown,
): error is UnsupportedOpenClawProviderError {
return (
error instanceof UnsupportedOpenClawProviderError ||
(error instanceof Error &&
error.name === 'UnsupportedOpenClawProviderError')
)
}
export function isSupportedOpenClawProvider(
providerType: string,
): providerType is SupportedOpenClawProvider {
return SUPPORTED_OPENCLAW_PROVIDERS.includes(
providerType as SupportedOpenClawProvider,
)
}
export function assertSupportedOpenClawProvider(
providerType?: string,
): SupportedOpenClawProvider | undefined {
if (!providerType) {
return undefined
}
if (!isSupportedOpenClawProvider(providerType)) {
throw new UnsupportedOpenClawProviderError(providerType)
}
return providerType
}
export function buildOpenClawModelRef(
providerType: SupportedOpenClawProvider,
modelId?: string,
): string | undefined {
return modelId ? `${providerType}/${modelId}` : undefined
}
export function deriveOpenClawProviderId(input: {
providerType?: string
providerName?: string
baseUrl?: string
}): string {
const source =
input.providerName?.trim() ||
input.baseUrl?.trim() ||
input.providerType?.trim() ||
'custom-provider'
const candidate = source
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
return candidate || 'custom-provider'
}
export function deriveOpenClawApiKeyEnvVar(providerId: string): string {
return `${providerId.toUpperCase().replace(/-/g, '_')}_API_KEY`
}
export function getOpenClawProviderEnvVar(
providerType: SupportedOpenClawProvider,
): string {
return PROVIDER_ENV_VARS[providerType]
}
export function resolveSupportedOpenClawProvider(input: {
providerType?: string
providerName?: string
baseUrl?: string
apiKey?: string
modelId?: string
}): ResolvedOpenClawProviderConfig {
if (!input.providerType) {
return { envValues: {} }
}
if (isSupportedOpenClawProvider(input.providerType)) {
const providerType = input.providerType
const envVar = getOpenClawProviderEnvVar(providerType)
return {
envValues: input.apiKey ? { [envVar]: input.apiKey } : {},
model: buildOpenClawModelRef(providerType, input.modelId),
providerType,
}
}
if (!input.baseUrl) {
throw new UnsupportedOpenClawProviderError(input.providerType)
}
const providerId = deriveOpenClawProviderId(input)
const apiKeyEnvVar = deriveOpenClawApiKeyEnvVar(providerId)
return {
envValues: input.apiKey ? { [apiKeyEnvVar]: input.apiKey } : {},
model: input.modelId ? `${providerId}/${input.modelId}` : undefined,
customProvider: {
providerId,
apiKeyEnvVar,
config: {
api: 'openai-completions',
baseUrl: input.baseUrl,
apiKey: `\${${apiKeyEnvVar}}`,
...(input.modelId
? {
models: [{ id: input.modelId, name: input.modelId }],
}
: {}),
},
},
}
}

View File

@@ -1,18 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export interface OpenClawStreamEvent {
type:
| 'text-delta'
| 'thinking'
| 'tool-start'
| 'tool-end'
| 'tool-output'
| 'lifecycle'
| 'done'
| 'error'
data: Record<string, unknown>
}

View File

@@ -0,0 +1,285 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Abstraction over the Podman CLI for container lifecycle management.
* Handles Podman machine init/start on macOS/Windows (where a Linux VM is required).
* On Linux, machine operations are no-ops since Podman runs natively.
*/
import { existsSync } from 'node:fs'
import { join } from 'node:path'
const isLinux = process.platform === 'linux'
const PODMAN_BUNDLE_PATH = ['bin', 'third_party', 'podman'] as const
export type LogFn = (msg: string) => void
function getPodmanBinaryName(platform: NodeJS.Platform): string {
return platform === 'win32' ? 'podman.exe' : 'podman'
}
export function resolveBundledPodmanPath(
resourcesDir?: string,
platform: NodeJS.Platform = process.platform,
): string | null {
if (!resourcesDir) return null
const bundledPath = join(
resourcesDir,
...PODMAN_BUNDLE_PATH,
getPodmanBinaryName(platform),
)
return existsSync(bundledPath) ? bundledPath : null
}
export class PodmanRuntime {
private podmanPath: string
private machineReady = false
constructor(config?: { podmanPath?: string }) {
this.podmanPath = config?.podmanPath ?? 'podman'
}
getPodmanPath(): string {
return this.podmanPath
}
async isPodmanAvailable(): Promise<boolean> {
try {
const proc = Bun.spawn([this.podmanPath, '--version'], {
stdout: 'ignore',
stderr: 'ignore',
})
return (await proc.exited) === 0
} catch {
return false
}
}
async getMachineStatus(): Promise<{
initialized: boolean
running: boolean
}> {
if (isLinux) return { initialized: true, running: true }
try {
const proc = Bun.spawn(
[this.podmanPath, 'machine', 'list', '--format', 'json'],
{ stdout: 'pipe', stderr: 'ignore' },
)
const output = await new Response(proc.stdout).text()
await proc.exited
const machines = JSON.parse(output) as Array<{
Running?: boolean
LastUp?: string
}>
if (!machines.length) return { initialized: false, running: false }
const machine = machines[0]
const running =
machine.Running === true || machine.LastUp === 'Currently running'
return { initialized: true, running }
} catch {
return { initialized: false, running: false }
}
}
async initMachine(onLog?: LogFn): Promise<void> {
if (isLinux) return
const proc = Bun.spawn(
[
this.podmanPath,
'machine',
'init',
'--cpus',
'2',
'--memory',
'2048',
'--disk-size',
'10',
],
{ stdout: 'ignore', stderr: 'pipe' },
)
await this.drainStderr(proc, onLog)
const code = await proc.exited
if (code !== 0)
throw new Error(`podman machine init failed with code ${code}`)
}
async startMachine(onLog?: LogFn): Promise<void> {
if (isLinux) return
const proc = Bun.spawn([this.podmanPath, 'machine', 'start'], {
stdout: 'ignore',
stderr: 'pipe',
})
await this.drainStderr(proc, onLog)
const code = await proc.exited
if (code !== 0)
throw new Error(`podman machine start failed with code ${code}`)
}
async stopMachine(): Promise<void> {
if (isLinux) return
const proc = Bun.spawn([this.podmanPath, 'machine', 'stop'], {
stdout: 'ignore',
stderr: 'ignore',
})
const code = await proc.exited
if (code !== 0)
throw new Error(`podman machine stop failed with code ${code}`)
this.machineReady = false
}
async ensureReady(onLog?: LogFn): Promise<void> {
if (this.machineReady) return
const status = await this.getMachineStatus()
if (!status.initialized) {
onLog?.('Initializing Podman machine...')
await this.initMachine(onLog)
}
if (!status.running) {
onLog?.('Starting Podman machine...')
await this.startMachine(onLog)
}
this.machineReady = true
}
async runCommand(
args: string[],
options?: {
cwd?: string
env?: Record<string, string>
onOutput?: (line: string) => void
},
): Promise<number> {
const useStreaming = !!options?.onOutput
const proc = Bun.spawn([this.podmanPath, ...args], {
cwd: options?.cwd,
env: options?.env ? { ...process.env, ...options.env } : undefined,
stdout: useStreaming ? 'pipe' : 'ignore',
stderr: useStreaming ? 'pipe' : 'ignore',
})
if (options?.onOutput) {
await Promise.all([
this.drainStream(proc.stdout ?? null, options.onOutput),
this.drainStream(proc.stderr ?? null, options.onOutput),
])
}
return proc.exited
}
/**
* Follow container logs. Returns a stop function that terminates the
* underlying `podman logs -f` process. Each output line is passed to
* onLine as-is.
*/
tailContainerLogs(containerName: string, onLine: LogFn): () => void {
const proc = Bun.spawn(
[this.podmanPath, 'logs', '-f', '--tail', '0', containerName],
{ stdout: 'pipe', stderr: 'pipe' },
)
void this.drainStream(proc.stdout ?? null, onLine)
void this.drainStream(proc.stderr ?? null, onLine)
let stopped = false
return () => {
if (stopped) return
stopped = true
try {
proc.kill()
} catch {
// process may already be gone
}
}
}
/**
* Lists running container names. Used to check whether non-BrowserOS
* containers are running before stopping the Podman machine.
*/
async listRunningContainers(): Promise<string[]> {
const proc = Bun.spawn([this.podmanPath, 'ps', '--format', '{{.Names}}'], {
stdout: 'pipe',
stderr: 'ignore',
})
const output = await new Response(proc.stdout).text()
await proc.exited
return output
.trim()
.split('\n')
.filter((name) => name.trim())
}
private async drainStderr(
proc: {
stderr: ReadableStream<Uint8Array> | null
exited: Promise<number>
},
onLog?: LogFn,
): Promise<void> {
if (!onLog || !proc.stderr) return
await this.drainStream(proc.stderr, onLog)
}
private async drainStream(
stream: ReadableStream<Uint8Array> | null,
onLine: (line: string) => void,
): Promise<void> {
if (!stream) return
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (trimmed) onLine(trimmed)
}
}
if (buffer.trim()) onLine(buffer.trim())
}
}
let runtime: PodmanRuntime | null = null
export function configurePodmanRuntime(config: {
resourcesDir?: string
podmanPath?: string
}): PodmanRuntime {
const podmanPath =
config.podmanPath ??
resolveBundledPodmanPath(config.resourcesDir) ??
'podman'
runtime = new PodmanRuntime({ podmanPath })
return runtime
}
export function getPodmanRuntime(): PodmanRuntime {
if (!runtime) runtime = new PodmanRuntime()
return runtime
}

View File

@@ -0,0 +1,200 @@
import {
type BROWSEROS_ROLE_TEMPLATES,
getBrowserOSRoleTemplate,
} from '@browseros/shared/constants/role-aware-agents'
import type {
BrowserOSAgentRoleId,
BrowserOSAgentRoleSummary,
BrowserOSCustomRoleInput,
BrowserOSRoleTemplate,
} from '@browseros/shared/types/role-aware-agents'
type RoleTemplate = (typeof BROWSEROS_ROLE_TEMPLATES)[number]
interface BootstrapRenderableRole {
name: string
shortDescription: string
longDescription: string
recommendedApps: string[]
boundaries: BrowserOSRoleTemplate['boundaries']
bootstrap: BrowserOSRoleTemplate['bootstrap']
}
export interface RoleBootstrapFiles {
'AGENTS.md': string
'SOUL.md': string
'TOOLS.md': string
'.browseros-role.json': string
}
export function resolveRoleTemplate(
roleId: BrowserOSAgentRoleId,
): RoleTemplate {
const role = getBrowserOSRoleTemplate(roleId)
if (!role) {
throw new Error(`Unknown BrowserOS role: ${roleId}`)
}
return role
}
export function buildRoleBootstrapFiles(input: {
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput
agentName: string
}): RoleBootstrapFiles {
const normalizedRole = normalizeRoleForBootstrap(input.role)
const roleId = 'id' in input.role ? input.role.id : undefined
return {
'AGENTS.md': normalizedRole.bootstrap.agentsMd,
'SOUL.md': normalizedRole.bootstrap.soulMd,
'TOOLS.md': normalizedRole.bootstrap.toolsMd,
'.browseros-role.json': `${JSON.stringify(
{
version: 1,
roleSource: roleId ? 'builtin' : 'custom',
roleId,
roleName: normalizedRole.name,
shortDescription: normalizedRole.shortDescription,
createdBy: 'browseros',
agentName: input.agentName,
},
null,
2,
)}\n`,
}
}
export function toRoleSummary(
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput,
): BrowserOSAgentRoleSummary {
const normalizedRole = normalizeRoleForBootstrap(role)
return {
roleSource: 'id' in role ? 'builtin' : 'custom',
roleId: 'id' in role ? role.id : undefined,
roleName: normalizedRole.name,
shortDescription: normalizedRole.shortDescription,
}
}
export function normalizeCustomRole(
role: BrowserOSCustomRoleInput,
): BootstrapRenderableRole {
const recommendedApps = Array.isArray(role.recommendedApps)
? role.recommendedApps.filter(
(app): app is string => typeof app === 'string',
)
: []
const boundaries = Array.isArray(role.boundaries) ? role.boundaries : []
return {
name: role.name,
shortDescription: role.shortDescription,
longDescription: role.longDescription,
recommendedApps,
boundaries,
bootstrap: {
agentsMd:
role.bootstrap?.agentsMd?.trim() ||
buildAgentsMd({
name: role.name,
longDescription: role.longDescription,
boundaries,
}),
soulMd:
role.bootstrap?.soulMd?.trim() ||
buildSoulMd({
name: role.name,
shortDescription: role.shortDescription,
longDescription: role.longDescription,
}),
toolsMd:
role.bootstrap?.toolsMd?.trim() ||
buildToolsMd({
boundaries,
recommendedApps,
}),
},
}
}
function normalizeRoleForBootstrap(
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput,
): BootstrapRenderableRole {
return 'id' in role ? role : normalizeCustomRole(role)
}
function buildAgentsMd(input: {
name: string
longDescription: string
boundaries: BrowserOSRoleTemplate['boundaries']
}): string {
const boundaryLines = input.boundaries
.map(
(boundary) =>
`- ${boundary.label}: ${boundary.description} Default mode: ${boundary.defaultMode}.`,
)
.join('\n')
return `# ${input.name}
You are the ${input.name} specialist for this workspace.
## Core Purpose
${input.longDescription}
## Operating Rules
${boundaryLines}
## Default Output Style
- concise
- action-oriented
- explicit about blockers and approvals
`
}
function buildSoulMd(input: {
name: string
shortDescription: string
longDescription: string
}): string {
return `# Operating Style
You act like a trusted ${input.name}.
## Working Posture
- calm
- structured
- direct
- explicit about tradeoffs
## Role Framing
${input.shortDescription}
${input.longDescription}
`
}
function buildToolsMd(input: {
boundaries: BrowserOSRoleTemplate['boundaries']
recommendedApps: string[]
}): string {
const boundaryLines = input.boundaries
.map((boundary) => `- ${boundary.label}: ${boundary.defaultMode}`)
.join('\n')
const appsLine =
input.recommendedApps.length > 0
? input.recommendedApps.join(', ')
: 'No specific apps configured yet.'
return `# Tooling Guidelines
- Use BrowserOS MCP for browser and connected SaaS tasks.
- Prefer read, summarize, and draft flows.
- Keep outputs in the workspace when possible so work remains inspectable.
## Recommended Apps
${appsLine}
## Boundary Defaults
${boundaryLines}
`
}

View File

@@ -1,97 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Runtime state for the OpenClaw gateway. Today this is just the host port
* we mapped the gateway container to, persisted so that a once-chosen port
* is reused across restarts when it's still free.
*/
import { existsSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { createServer } from 'node:net'
import { join } from 'node:path'
import { OPENCLAW_GATEWAY_CONTAINER_PORT } from '@browseros/shared/constants/openclaw'
import { getOpenClawStateDir } from './openclaw-env'
const RUNTIME_STATE_FILE = 'runtime-state.json'
interface RuntimeState {
gatewayPort: number
}
function getRuntimeStatePath(openclawDir: string): string {
return join(getOpenClawStateDir(openclawDir), RUNTIME_STATE_FILE)
}
export async function readPersistedGatewayPort(
openclawDir: string,
): Promise<number | null> {
const path = getRuntimeStatePath(openclawDir)
if (!existsSync(path)) return null
try {
const parsed = JSON.parse(
await readFile(path, 'utf-8'),
) as Partial<RuntimeState>
if (
typeof parsed.gatewayPort === 'number' &&
Number.isInteger(parsed.gatewayPort) &&
parsed.gatewayPort > 0 &&
parsed.gatewayPort <= 65535
) {
return parsed.gatewayPort
}
return null
} catch {
return null
}
}
async function writePersistedGatewayPort(
openclawDir: string,
port: number,
): Promise<void> {
await mkdir(getOpenClawStateDir(openclawDir), { recursive: true })
const state: RuntimeState = { gatewayPort: port }
await writeFile(
getRuntimeStatePath(openclawDir),
`${JSON.stringify(state, null, 2)}\n`,
)
}
function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = createServer()
server.once('error', () => resolve(false))
server.once('listening', () => {
server.close(() => resolve(true))
})
server.listen(port, '127.0.0.1')
})
}
async function findAvailablePort(startPort: number): Promise<number> {
let port = startPort
while (!(await isPortAvailable(port))) {
port++
}
return port
}
/**
* Pick a host port for the gateway container and persist it. Prefers the
* previously persisted port when it's still bindable; otherwise scans
* upward from OPENCLAW_GATEWAY_CONTAINER_PORT until a free port is found.
*/
export async function allocateGatewayPort(
openclawDir: string,
): Promise<number> {
const persisted = await readPersistedGatewayPort(openclawDir)
if (persisted !== null && (await isPortAvailable(persisted))) {
return persisted
}
const port = await findAvailablePort(OPENCLAW_GATEWAY_CONTAINER_PORT)
await writePersistedGatewayPort(openclawDir, port)
return port
}

View File

@@ -2,7 +2,6 @@ import {
OPENCLAW_CONTAINER_HOME,
OPENCLAW_TERMINAL_SHELL,
} from '@browseros/shared/constants/openclaw'
import { buildNerdctlCommand } from '../../../lib/container'
import { logger } from '../../../lib/logger'
export const TERMINAL_HOME_DIR = OPENCLAW_CONTAINER_HOME
@@ -12,9 +11,7 @@ const TERMINAL_NAME = 'xterm-256color'
interface TerminalSessionDeps {
containerName: string
limaHome: string
limactlPath: string
vmName: string
podmanPath: string
workingDir: string
onExit: (exitCode: number) => void
onOutput: (data: string) => void
@@ -27,44 +24,32 @@ export interface TerminalSession {
}
export function buildTerminalExecCommand(
limactlPath: string,
vmName: string,
podmanPath: string,
containerName: string,
workingDir: string,
): string[] {
return [
limactlPath,
'shell',
vmName,
'--',
...buildNerdctlCommand([
'exec',
'-it',
'-w',
workingDir,
containerName,
OPENCLAW_TERMINAL_SHELL,
]),
podmanPath,
'exec',
'-it',
'-w',
workingDir,
containerName,
OPENCLAW_TERMINAL_SHELL,
]
}
export function buildTerminalEnv(limaHome: string): NodeJS.ProcessEnv {
return { ...process.env, LIMA_HOME: limaHome, TERM: TERMINAL_NAME }
}
export function createTerminalSession(
deps: TerminalSessionDeps,
): TerminalSession {
const decoder = new TextDecoder()
const proc = Bun.spawn(
buildTerminalExecCommand(
deps.limactlPath,
deps.vmName,
deps.podmanPath,
deps.containerName,
deps.workingDir,
),
{
cwd: '/',
terminal: {
cols: DEFAULT_COLS,
rows: DEFAULT_ROWS,
@@ -73,7 +58,7 @@ export function createTerminalSession(
if (chunk) deps.onOutput(chunk)
},
},
env: buildTerminalEnv(deps.limaHome),
env: { ...process.env, TERM: TERMINAL_NAME },
},
)
let closed = false

View File

@@ -517,45 +517,15 @@ export class Browser {
return null
}
private async resolveWindowIdForNewPage(opts?: {
hidden?: boolean
windowId?: number
}): Promise<number | undefined> {
if (!opts?.hidden) {
return opts?.windowId
}
if (opts.windowId !== undefined) {
const windows = await this.listWindows()
const targetWindow = windows.find(
(window) => window.windowId === opts.windowId,
)
if (targetWindow && !targetWindow.isVisible) {
return targetWindow.windowId
}
if (targetWindow?.isVisible) {
logger.warn(
'Requested hidden page target window is visible, creating a new hidden window instead',
{
requestedWindowId: opts.windowId,
},
)
}
}
const hiddenWindow = await this.createWindow({ hidden: true })
return hiddenWindow.windowId
}
async newPage(
url: string,
opts?: { hidden?: boolean; background?: boolean; windowId?: number },
): Promise<number> {
const windowId = await this.resolveWindowIdForNewPage(opts)
const createResult = await this.cdp.Browser.createTab({
url,
...(opts?.hidden !== undefined && { hidden: opts.hidden }),
...(opts?.background !== undefined && { background: opts.background }),
...(windowId !== undefined && { windowId }),
...(opts?.windowId !== undefined && { windowId: opts.windowId }),
})
const tabId = (createResult.tab as TabInfo).tabId
@@ -583,7 +553,7 @@ export class Browser {
loadProgress: tabInfo.loadProgress,
isPinned: tabInfo.isPinned,
isHidden: tabInfo.isHidden,
windowId: tabInfo.windowId ?? windowId,
windowId: tabInfo.windowId,
index: tabInfo.index,
groupId: tabInfo.groupId,
})

View File

@@ -7,16 +7,7 @@ import type { ServerDiscoveryConfig } from '@browseros/shared/types/server-confi
import { logger } from './logger'
export function getBrowserosDir(): string {
const dirName =
process.env.NODE_ENV === 'development'
? PATHS.DEV_BROWSEROS_DIR_NAME
: PATHS.BROWSEROS_DIR_NAME
return join(homedir(), dirName)
}
export function logDevelopmentBrowserosDir(): void {
if (process.env.NODE_ENV !== 'development') return
logger.info(`Using development BrowserOS directory: ${getBrowserosDir()}`)
return join(homedir(), PATHS.BROWSEROS_DIR_NAME)
}
export function getMemoryDir(): string {
@@ -44,49 +35,9 @@ export function getBuiltinSkillsDir(): string {
}
export function getOpenClawDir(): string {
return join(getVmStateDir(), PATHS.OPENCLAW_DIR_NAME)
}
export function getLegacyOpenClawDir(): string {
return join(getBrowserosDir(), PATHS.OPENCLAW_DIR_NAME)
}
export function getCacheDir(): string {
return join(getBrowserosDir(), PATHS.CACHE_DIR_NAME)
}
export function getVmCacheDir(): string {
return join(getCacheDir(), 'vm')
}
export function getLimaHomeDir(): string {
return join(getBrowserosDir(), 'lima')
}
export function getVmStateDir(): string {
return join(getBrowserosDir(), 'vm')
}
export function getVmDisksDir(): string {
return getVmCacheDir()
}
export function getAgentCacheDir(): string {
return join(getVmCacheDir(), 'images')
}
export function getLazyMonitoringDir(): string {
return join(getBrowserosDir(), 'lazy-monitoring')
}
export function getLazyMonitoringRunsDir(): string {
return join(getLazyMonitoringDir(), 'runs')
}
export function getLazyMonitoringRunDir(runId: string): string {
return join(getLazyMonitoringRunsDir(), runId)
}
export function getServerConfigPath(): string {
return join(getBrowserosDir(), PATHS.SERVER_CONFIG_FILE_NAME)
}
@@ -106,13 +57,10 @@ export function removeServerConfigSync(): void {
}
export async function ensureBrowserosDir(): Promise<void> {
logDevelopmentBrowserosDir()
await mkdir(getMemoryDir(), { recursive: true })
await mkdir(getSkillsDir(), { recursive: true })
await mkdir(getBuiltinSkillsDir(), { recursive: true })
await mkdir(getSessionsDir(), { recursive: true })
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
await mkdir(getAgentCacheDir(), { recursive: true })
}
export async function cleanOldSessions(): Promise<void> {

View File

@@ -1,209 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { ContainerCliError } from '../vm/errors'
import { LimaCli } from '../vm/lima-cli'
import type { ContainerSpec, LogFn, MountSpec, PortMapping } from './types'
export function buildNerdctlCommand(args: string[]): string[] {
return ['nerdctl', ...args]
}
export interface ContainerCliConfig {
limactlPath: string
limaHome: string
vmName: string
sshPath?: string
}
export interface ContainerCommandResult {
exitCode: number
stdout: string
stderr: string
}
export class ContainerCli {
private readonly lima: LimaCli
constructor(private readonly cfg: ContainerCliConfig) {
this.lima = new LimaCli({
limactlPath: cfg.limactlPath,
limaHome: cfg.limaHome,
sshPath: cfg.sshPath,
})
}
async imageExists(ref: string): Promise<boolean> {
const result = await this.runCommand(['image', 'inspect', ref])
return result.exitCode === 0
}
async pullImage(ref: string, onLog?: LogFn): Promise<void> {
await this.runRequired(['pull', ref], onLog)
}
async loadImage(tarballPath: string, onLog?: LogFn): Promise<string[]> {
const result = await this.runRequired(['load', '-i', tarballPath], onLog)
return parseLoadedImageRefs(result.stdout)
}
async createContainer(spec: ContainerSpec, onLog?: LogFn): Promise<void> {
await this.runRequired(buildCreateArgs(spec), onLog)
}
async startContainer(name: string, onLog?: LogFn): Promise<void> {
await this.runRequired(['start', name], onLog)
}
async stopContainer(name: string, onLog?: LogFn): Promise<void> {
const result = await this.runCommand(['stop', name], onLog)
if (result.exitCode === 0 || isNoSuchContainer(result.stderr)) return
throw this.commandError(['stop', name], result)
}
async removeContainer(
name: string,
opts?: { force?: boolean },
onLog?: LogFn,
): Promise<void> {
const args = ['rm']
if (opts?.force) args.push('-f')
args.push(name)
const result = await this.runCommand(args, onLog)
if (result.exitCode === 0 || isNoSuchContainer(result.stderr)) return
throw this.commandError(args, result)
}
async exec(name: string, cmd: string[], onLog?: LogFn): Promise<number> {
const result = await this.runCommand(['exec', name, ...cmd], onLog)
return result.exitCode
}
async ps(opts?: { namesOnly?: boolean }): Promise<string[]> {
const args = opts?.namesOnly ? ['ps', '--format', '{{.Names}}'] : ['ps']
const result = await this.runRequired(args)
return result.stdout
.trim()
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
}
tailLogs(name: string, onLine: LogFn): () => void {
const proc = this.lima.spawnShell(
this.cfg.vmName,
buildNerdctlCommand(['logs', '-f', '-n', '0', name]),
{ onStdout: onLine, onStderr: onLine },
)
let stopped = false
return () => {
if (stopped) return
stopped = true
proc.kill()
}
}
async runCommand(
args: string[],
onLog?: LogFn,
): Promise<ContainerCommandResult> {
const stdoutLines: string[] = []
const stderrLines: string[] = []
const exitCode = await this.lima.shell(
this.cfg.vmName,
buildNerdctlCommand(args),
{
onStdout: (line) => {
stdoutLines.push(line)
onLog?.(line)
},
onStderr: (line) => {
stderrLines.push(line)
onLog?.(line)
},
},
)
return {
exitCode,
stdout: linesToOutput(stdoutLines),
stderr: stderrLines.join('\n'),
}
}
private async runRequired(
args: string[],
onLog?: LogFn,
): Promise<ContainerCommandResult> {
const result = await this.runCommand(args, onLog)
if (result.exitCode === 0) return result
throw this.commandError(args, result)
}
private commandError(
args: string[],
result: ContainerCommandResult,
): ContainerCliError {
return new ContainerCliError(
`nerdctl ${args.join(' ')}`,
result.exitCode,
result.stderr.trim(),
)
}
}
function buildCreateArgs(spec: ContainerSpec): string[] {
const args = ['create', '--name', spec.name]
if (spec.restart) args.push('--restart', spec.restart)
for (const port of spec.ports ?? []) args.push('-p', portArg(port))
if (spec.envFile) args.push('--env-file', spec.envFile)
for (const [key, value] of Object.entries(spec.env ?? {})) {
args.push('-e', `${key}=${value}`)
}
for (const mount of spec.mounts ?? []) args.push('-v', mountArg(mount))
for (const host of spec.addHosts ?? []) args.push('--add-host', host)
if (spec.health) {
args.push('--health-cmd', spec.health.cmd)
if (spec.health.interval)
args.push('--health-interval', spec.health.interval)
if (spec.health.timeout) args.push('--health-timeout', spec.health.timeout)
if (spec.health.retries !== undefined) {
args.push('--health-retries', String(spec.health.retries))
}
}
args.push(spec.image)
args.push(...(spec.command ?? []))
return args
}
function portArg(port: PortMapping): string {
const host = port.hostIp ? `${port.hostIp}:${port.hostPort}` : port.hostPort
return `${host}:${port.containerPort}`
}
function mountArg(mount: MountSpec): string {
return `${mount.source}:${mount.target}${mount.readonly ? ':ro' : ''}`
}
function parseLoadedImageRefs(stdout: string): string[] {
return stdout
.split('\n')
.map((line) => line.match(/^Loaded image(?:\(s\))?:\s*(.+)$/i)?.[1]?.trim())
.filter((ref): ref is string => !!ref)
}
function isNoSuchContainer(stderr: string): boolean {
const lower = stderr.toLowerCase()
return lower.includes('no such container') || lower.includes('not found')
}
function linesToOutput(lines: string[]): string {
if (lines.length === 0) return ''
return `${lines.join('\n')}\n`
}

View File

@@ -1,64 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { basename, join } from 'node:path'
import { ContainerCliError, ImageLoadError } from '../vm/errors'
import type { VmManifest } from '../vm/manifest'
import type { Arch } from '../vm/paths'
import { getImageCacheDir, hostPathToGuest } from '../vm/paths'
import type { ContainerCli } from './container-cli'
import type { LogFn } from './types'
export class ImageLoader {
constructor(
private readonly cli: ContainerCli,
private readonly manifest: VmManifest,
private readonly arch: Arch,
private readonly browserosRoot?: string,
) {}
async ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void> {
if (await this.cli.imageExists(ref)) return
const tarball = this.resolveTarball(ref)
const hostPath = join(
getImageCacheDir(this.browserosRoot),
basename(tarball.key),
)
const guestPath = hostPathToGuest(hostPath, this.browserosRoot)
try {
await this.cli.loadImage(guestPath, onLog)
} catch (error) {
if (error instanceof ContainerCliError) {
throw new ImageLoadError(ref, `load failed: ${error.stderr}`, error)
}
throw error
}
if (!(await this.cli.imageExists(ref))) {
throw new ImageLoadError(
ref,
`image not present after successful load of ${guestPath}`,
)
}
}
private resolveTarball(
ref: string,
): VmManifest['agents'][string]['tarballs'][Arch] {
for (const agent of Object.values(this.manifest.agents)) {
if (`${agent.image}:${agent.version}` !== ref) continue
const tarball = agent.tarballs[this.arch]
if (!tarball) {
throw new ImageLoadError(ref, `no ${this.arch} tarball in manifest`)
}
return tarball
}
throw new ImageLoadError(ref, `no agent in manifest matches ${ref}`)
}
}

View File

@@ -1,9 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export * from './container-cli'
export * from './image-loader'
export * from './types'

View File

@@ -1,44 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export type LogFn = (msg: string) => void
export interface PortMapping {
hostIp?: string
hostPort: number
containerPort: number
}
export interface MountSpec {
source: string
target: string
readonly?: boolean
}
export interface HealthConfig {
cmd: string
interval?: string
timeout?: string
retries?: number
}
export interface ContainerSpec {
name: string
image: string
restart?: 'no' | 'unless-stopped' | 'always'
ports?: PortMapping[]
env?: Record<string, string>
envFile?: string
mounts?: MountSpec[]
addHosts?: string[]
health?: HealthConfig
command?: string[]
}
export interface LogLine {
stream: 'stdout' | 'stderr'
line: string
}

View File

@@ -1,60 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export class VmError extends Error {
constructor(message: string) {
super(message)
this.name = new.target.name
}
}
export class VmNotReadyError extends VmError {}
export class VmStateCorruptedError extends VmError {}
export class LimaCommandError extends VmError {
constructor(
command: string,
public readonly exitCode: number,
public readonly stderr: string,
) {
super(`${command} failed with exit code ${exitCode}: ${stderr}`)
}
}
export class ContainerCliError extends VmError {
constructor(
command: string,
public readonly exitCode: number,
public readonly stderr: string,
) {
super(`${command} failed with exit code ${exitCode}: ${stderr}`)
}
}
export class ImageLoadError extends VmError {
constructor(
public readonly imageRef: string,
message: string,
public override readonly cause?: unknown,
) {
super(`failed to load image ${imageRef}: ${message}`)
}
}
export class ManifestMissingError extends VmError {
constructor(public readonly manifestPath: string) {
super(manifestMissingMessage(manifestPath))
}
}
function manifestMissingMessage(manifestPath: string): string {
const message = `VM manifest is missing at ${manifestPath}`
if (process.env.NODE_ENV === 'development') {
return `${message}; run bun run dev:setup before starting the server`
}
return message
}

View File

@@ -1,13 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export * from './errors'
export * from './lima-cli'
export * from './lima-config'
export * from './manifest'
export * from './paths'
export * from './telemetry'
export * from './vm-runtime'

View File

@@ -1,270 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { existsSync } from 'node:fs'
import { logger } from '../logger'
import { LimaCommandError, VmNotReadyError } from './errors'
import { getLimaSshConfigPath } from './paths'
import { VM_TELEMETRY_EVENTS } from './telemetry'
export interface LimaListEntry {
name: string
status: string
dir: string
}
export interface LimaCliConfig {
limactlPath: string
limaHome: string
sshPath?: string
}
export interface LimaShellStreams {
onStdout?: (line: string) => void
onStderr?: (line: string) => void
}
export interface LimaShellProcess {
kill: () => void
exited: Promise<number>
}
const LIMA_VERBOSE_LOGGING = false
export class LimaCli {
constructor(private readonly cfg: LimaCliConfig) {}
async list(): Promise<LimaListEntry[]> {
const result = await this.run(['list', '--format', 'json'])
if (!result.stdout.trim()) {
logger.debug('Lima list returned no instances', {
limaHome: this.cfg.limaHome,
})
return []
}
const entries = parseLimaList(result.stdout)
logger.debug('Lima list parsed', {
limaHome: this.cfg.limaHome,
count: entries.length,
entries: entries.map((e) => ({ name: e.name, status: e.status })),
})
return entries
}
async create(name: string, yamlPath: string): Promise<void> {
await this.runChecked('create', [
'create',
'--tty=false',
`--name=${name}`,
yamlPath,
])
}
async start(name: string): Promise<void> {
logger.info('Invoking limactl start', {
vmName: name,
limaHome: this.cfg.limaHome,
note: 'this command blocks until boot reaches READY; may take 40-120s on first boot',
})
await this.runChecked('start', ['start', '--tty=false', name])
}
async stop(name: string): Promise<void> {
await this.runChecked('stop', ['stop', name])
}
async delete(name: string): Promise<void> {
await this.runChecked('delete', ['delete', '--force', name])
}
async shell(
name: string,
args: string[],
streams?: LimaShellStreams,
): Promise<number> {
const proc = this.spawnShell(name, args, streams)
return proc.exited
}
spawnShell(
name: string,
args: string[],
streams?: LimaShellStreams,
): LimaShellProcess {
const configPath = getLimaSshConfigPath(this.cfg.limaHome, name)
if (!existsSync(configPath)) {
throw new VmNotReadyError(
`lima ssh.config not found at ${configPath}; VM has not been started`,
)
}
const proc = Bun.spawn(
[
this.cfg.sshPath ?? 'ssh',
'-F',
configPath,
`lima-${name}`,
shellQuoteCommand(args),
],
{
cwd: '/',
env: this.env(),
stdout: streams?.onStdout ? 'pipe' : 'ignore',
stderr: streams?.onStderr ? 'pipe' : 'ignore',
},
)
const drained = Promise.all([
drainStream(proc.stdout ?? null, streams?.onStdout),
drainStream(proc.stderr ?? null, streams?.onStderr),
])
const exited = drained.then(() => proc.exited)
return {
exited,
kill: () => {
try {
proc.kill()
} catch {
return
}
},
}
}
private async runChecked(command: string, args: string[]): Promise<void> {
const result = await this.run(args)
if (result.exitCode !== 0) {
throw new LimaCommandError(
`limactl ${command}`,
result.exitCode,
result.stderr,
)
}
}
private async run(args: string[]): Promise<{
exitCode: number
stdout: string
stderr: string
}> {
const started = Date.now()
const proc = Bun.spawn([this.cfg.limactlPath, ...args], {
env: this.env(),
stdout: 'pipe',
stderr: 'pipe',
})
logger.debug(VM_TELEMETRY_EVENTS.limaSpawn, {
pid: proc.pid,
args,
limaHome: this.cfg.limaHome,
})
const stderrLogger = LIMA_VERBOSE_LOGGING
? (line: string) => {
logger.debug(VM_TELEMETRY_EVENTS.limaStderrChunk, {
pid: proc.pid,
firstArg: args[0],
line,
})
}
: undefined
const [stdout, stderr, exitCode] = await Promise.all([
drainToString(proc.stdout),
drainToString(proc.stderr, stderrLogger),
proc.exited,
])
const durationMs = Date.now() - started
logger.debug(VM_TELEMETRY_EVENTS.limaExit, {
pid: proc.pid,
firstArg: args[0],
exitCode,
durationMs,
stdoutLen: stdout.length,
stderrLen: stderr.length,
})
return { exitCode, stdout, stderr }
}
private env(): NodeJS.ProcessEnv {
return { ...process.env, LIMA_HOME: this.cfg.limaHome }
}
}
async function drainToString(
stream: ReadableStream<Uint8Array> | null,
onLine?: (line: string) => void,
): Promise<string> {
if (!stream) return ''
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
let output = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
output += chunk
buffer += chunk
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (trimmed && onLine) onLine(trimmed)
}
}
if (buffer.trim() && onLine) onLine(buffer.trim())
return output
}
function parseLimaList(output: string): LimaListEntry[] {
const trimmed = output.trim()
try {
const parsed = JSON.parse(trimmed) as unknown
if (Array.isArray(parsed)) return parsed.map(toLimaListEntry)
return [toLimaListEntry(parsed)]
} catch {
return trimmed.split('\n').map((line) => toLimaListEntry(JSON.parse(line)))
}
}
function toLimaListEntry(input: unknown): LimaListEntry {
const entry = input as Partial<LimaListEntry>
return {
name: entry.name ?? '',
status: entry.status ?? '',
dir: entry.dir ?? '',
}
}
function shellQuoteCommand(args: string[]): string {
return args.map(shellQuote).join(' ')
}
function shellQuote(arg: string): string {
return `'${arg.replaceAll("'", "'\\''")}'`
}
async function drainStream(
stream: ReadableStream<Uint8Array> | null,
onLine?: (line: string) => void,
): Promise<void> {
if (!stream || !onLine) return
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line.trim()) onLine(line.trim())
}
}
if (buffer.trim()) onLine(buffer.trim())
}

View File

@@ -1,29 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export function renderLimaTemplate(
template: string,
cfg: {
vmStateDir: string
imageCacheDir: string
},
): string {
const mounts = [
'mounts:',
`- location: "${cfg.vmStateDir}"`,
' mountPoint: "/mnt/browseros/vm"',
' writable: true',
`- location: "${cfg.imageCacheDir}"`,
' mountPoint: "/mnt/browseros/cache/images"',
' writable: false',
].join('\n')
if (!template.includes('mounts: []')) {
throw new Error('BrowserOS VM Lima template is missing mounts: [] marker')
}
return template.replace('mounts: []', mounts)
}

View File

@@ -1,102 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { existsSync } from 'node:fs'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { ManifestMissingError } from './errors'
import type { Arch } from './paths'
import { getCachedManifestPath, getInstalledManifestPath } from './paths'
export interface VmArtifact {
key: string
sha256: string
sizeBytes: number
}
export interface VmAgentEntry {
image: string
version: string
tarballs: Record<Arch, VmArtifact>
}
export interface VmManifest {
schemaVersion: number
updatedAt: string
agents: Record<string, VmAgentEntry>
}
export type VersionComparison = 'same' | 'upgrade' | 'downgrade' | 'fresh'
export async function readCachedManifest(
browserosRoot?: string,
): Promise<VmManifest> {
const manifestPath = getCachedManifestPath(browserosRoot)
if (!existsSync(manifestPath)) throw new ManifestMissingError(manifestPath)
return readManifest(manifestPath)
}
export async function readInstalledManifest(
browserosRoot?: string,
): Promise<VmManifest | null> {
const manifestPath = getInstalledManifestPath(browserosRoot)
if (!existsSync(manifestPath)) return null
return readManifest(manifestPath)
}
export async function writeInstalledManifest(
manifest: VmManifest,
browserosRoot?: string,
): Promise<void> {
const manifestPath = getInstalledManifestPath(browserosRoot)
await mkdir(dirname(manifestPath), { recursive: true })
const tempPath = `${manifestPath}.${process.pid}.${Date.now()}.tmp`
await writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\n`)
await rename(tempPath, manifestPath)
}
export function compareVersions(
installed: VmManifest | null,
cached: VmManifest,
): VersionComparison {
if (!installed) return 'fresh'
const comparison = compareVersionStrings(
installed.updatedAt,
cached.updatedAt,
)
if (comparison === 0) return 'same'
return comparison < 0 ? 'upgrade' : 'downgrade'
}
export function agentForArch(
manifest: VmManifest,
name: string,
arch: Arch,
): {
image: string
version: string
tarball: VmManifest['agents'][string]['tarballs'][Arch]
} {
const agent = manifest.agents[name]
if (!agent) throw new Error(`missing agent in VM manifest: ${name}`)
const tarball = agent.tarballs[arch]
if (!tarball) throw new Error(`missing ${arch} tarball for agent ${name}`)
return {
image: agent.image,
version: agent.version,
tarball,
}
}
async function readManifest(path: string): Promise<VmManifest> {
return JSON.parse(await readFile(path, 'utf8')) as VmManifest
}
function compareVersionStrings(left: string, right: string): number {
if (left < right) return -1
if (left > right) return 1
return 0
}

View File

@@ -1,208 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { accessSync, constants, existsSync } from 'node:fs'
import { homedir, arch as osArch } from 'node:os'
import {
delimiter,
dirname,
isAbsolute,
join,
relative,
resolve,
sep,
} from 'node:path'
import { PATHS } from '@browseros/shared/constants/paths'
export const VM_NAME = 'browseros-vm'
export const GUEST_VM_STATE = '/mnt/browseros/vm'
export const GUEST_IMAGE_CACHE = '/mnt/browseros/cache/images'
const HOST_LIMACTL_BINARY = 'limactl'
export type Arch = 'arm64' | 'x64'
function rootDir(): string {
const base =
process.env.NODE_ENV === 'development'
? PATHS.DEV_BROWSEROS_DIR_NAME
: PATHS.BROWSEROS_DIR_NAME
return join(homedir(), base)
}
export function detectArch(arch: NodeJS.Architecture = osArch()): Arch {
if (arch === 'arm64') return 'arm64'
if (arch === 'x64') return 'x64'
throw new Error(`unsupported host arch: ${arch}`)
}
export function getLimaHomeDir(browserosRoot = rootDir()): string {
return join(browserosRoot, 'lima')
}
export function getVmStateDir(browserosRoot = rootDir()): string {
return join(browserosRoot, 'vm')
}
export function getVmCacheDir(browserosRoot = rootDir()): string {
return join(browserosRoot, PATHS.CACHE_DIR_NAME, 'vm')
}
export function getImageCacheDir(browserosRoot = rootDir()): string {
return join(getVmCacheDir(browserosRoot), 'images')
}
export function getCachedManifestPath(browserosRoot = rootDir()): string {
return join(getVmCacheDir(browserosRoot), 'manifest.json')
}
export function getInstalledManifestPath(browserosRoot = rootDir()): string {
return join(getVmStateDir(browserosRoot), 'manifest.json')
}
export function getContainerdSocketPath(browserosRoot = rootDir()): string {
return join(getLimaHomeDir(browserosRoot), VM_NAME, 'sock', 'containerd.sock')
}
export function getLimaSocketPath(browserosRoot = rootDir()): string {
return getContainerdSocketPath(browserosRoot)
}
export function getLimaSshConfigPath(limaHome: string, name: string): string {
return join(limaHome, name, 'ssh.config')
}
export function compressedDiskPath(
version: string,
arch: Arch,
browserosRoot = rootDir(),
): string {
return join(
getVmCacheDir(browserosRoot),
`browseros-vm-${version}-${arch}.qcow2.zst`,
)
}
export function decompressedDiskPath(
version: string,
arch: Arch,
browserosRoot = rootDir(),
): string {
return join(
getVmCacheDir(browserosRoot),
`browseros-vm-${version}-${arch}.qcow2`,
)
}
export function resolveBundledLimactl(resourcesDir: string): string {
if (usesHostVmTools()) return resolveHostLimactl()
const candidate = join(resourcesDir, 'bin', 'third_party', 'lima', 'limactl')
if (!existsSync(candidate)) {
throw new Error(
`bundled limactl not found at ${candidate}; see the build-tools README and run bun run cache:sync`,
)
}
return candidate
}
function resolveHostLimactl(): string {
const resolved = findExecutableOnPath(HOST_LIMACTL_BINARY)
if (resolved) return resolved
throw new Error(
'Lima is not installed or limactl is not on PATH. Install with brew install lima.',
)
}
export function resolveBundledLimaTemplate(resourcesDir: string): string {
if (usesHostVmTools()) {
const sourceTemplate = findSourceLimaTemplate(resourcesDir)
if (sourceTemplate) return sourceTemplate
}
const candidate = join(resourcesDir, 'vm', 'browseros-vm.yaml')
if (!existsSync(candidate)) {
throw new Error(
`bundled Lima template not found at ${candidate}; see the build-tools README and run bun run cache:sync`,
)
}
return candidate
}
function usesHostVmTools(): boolean {
return (
process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'
)
}
function findExecutableOnPath(binary: string): string | null {
const pathEnv = process.env.PATH
if (!pathEnv) return null
for (const dir of pathEnv.split(delimiter)) {
if (!dir) continue
const candidate = join(dir, binary)
try {
accessSync(candidate, constants.X_OK)
return candidate
} catch {}
}
return null
}
function findSourceLimaTemplate(resourcesDir: string): string | null {
let current = resolve(resourcesDir)
while (true) {
const rootCandidate = join(
current,
'packages',
'build-tools',
'template',
'browseros-vm.yaml',
)
if (existsSync(rootCandidate)) return rootCandidate
const packageCandidate = join(
current,
'build-tools',
'template',
'browseros-vm.yaml',
)
if (existsSync(packageCandidate)) return packageCandidate
const parent = dirname(current)
if (parent === current) return null
current = parent
}
}
export function hostPathToGuest(
hostPath: string,
browserosRoot = rootDir(),
): string {
const vmState = getVmStateDir(browserosRoot)
const imageCache = getImageCacheDir(browserosRoot)
const vmStateRelative = mountedRelativePath(vmState, hostPath)
if (vmStateRelative !== null)
return guestPath(GUEST_VM_STATE, vmStateRelative)
const imageCacheRelative = mountedRelativePath(imageCache, hostPath)
if (imageCacheRelative !== null) {
return guestPath(GUEST_IMAGE_CACHE, imageCacheRelative)
}
throw new Error(`host path ${hostPath} is not under any known guest mount`)
}
function mountedRelativePath(parent: string, child: string): string | null {
const path = relative(parent, child)
if (path === '') return ''
if (path.startsWith('..') || isAbsolute(path)) return null
return path
}
function guestPath(root: string, relativePath: string): string {
if (!relativePath) return root
return `${root}/${relativePath.split(sep).join('/')}`
}

View File

@@ -1,36 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const VM_TELEMETRY_EVENTS = {
ensureReadyStart: 'vm.ensure_ready.start',
ensureReadyOk: 'vm.ensure_ready.ok',
ensureReadyBranch: 'vm.ensure_ready.branch',
create: 'vm.create',
start: 'vm.start',
stop: 'vm.stop',
upgradeDetected: 'vm.upgrade.detected',
downgradeDetected: 'vm.downgrade.detected',
upgradeSwap: 'vm.upgrade.swap',
upgradeReplay: 'vm.upgrade.replay',
resetDetected: 'vm.reset.detected',
resetOk: 'vm.reset.ok',
nerdctlWaitStart: 'vm.nerdctl_wait.start',
nerdctlWaitOk: 'vm.nerdctl_wait.ok',
nerdctlWaitPoll: 'vm.nerdctl_wait.poll',
nerdctlWaitTimeout: 'vm.nerdctl_wait.timeout',
manifestMissing: 'vm.manifest.missing',
manifestCompared: 'vm.manifest.compared',
manifestWritten: 'vm.manifest.written',
migrationOpenClawMoved: 'vm.migration.openclaw_moved',
limaSpawn: 'vm.lima.spawn',
limaExit: 'vm.lima.exit',
limaStderrChunk: 'vm.lima.stderr_chunk',
provisionYamlWrite: 'vm.provision.yaml_write',
provisionCreateStart: 'vm.provision.create.start',
provisionCreateOk: 'vm.provision.create.ok',
provisionStartBegin: 'vm.provision.start.begin',
provisionStartOk: 'vm.provision.start.ok',
} as const

View File

@@ -1,325 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { logger } from '../logger'
import { LimaCommandError, VmError, VmNotReadyError } from './errors'
import { LimaCli } from './lima-cli'
import { renderLimaTemplate } from './lima-config'
import {
compareVersions,
readCachedManifest,
readInstalledManifest,
writeInstalledManifest,
} from './manifest'
import { getImageCacheDir, getVmStateDir, VM_NAME } from './paths'
import { VM_TELEMETRY_EVENTS } from './telemetry'
export type LogFn = (msg: string) => void
const ROOTLESS_CONTAINERD_MARKER = 'runtime:containerd-rootless'
export interface VmRuntimeDeps {
limactlPath: string
limaHome: string
sshPath?: string
templatePath?: string
browserosRoot?: string
readinessTimeoutMs?: number
readinessPollMs?: number
}
export class VmRuntime {
private readonly cli: LimaCli
private readonly readinessTimeoutMs: number
private readonly readinessPollMs: number
private defaultGateway: string | null = null
constructor(private readonly deps: VmRuntimeDeps) {
this.cli = new LimaCli({
limactlPath: deps.limactlPath,
limaHome: deps.limaHome,
sshPath: deps.sshPath,
})
this.readinessTimeoutMs = deps.readinessTimeoutMs ?? 60_000
this.readinessPollMs = deps.readinessPollMs ?? 500
}
async ensureReady(onLog?: LogFn): Promise<void> {
const started = Date.now()
logger.info(VM_TELEMETRY_EVENTS.ensureReadyStart, {
limaHome: this.deps.limaHome,
browserosRoot: this.deps.browserosRoot,
templatePath: this.deps.templatePath,
limactlPath: this.deps.limactlPath,
})
const cached = await readCachedManifest(this.deps.browserosRoot)
const installed = await readInstalledManifest(this.deps.browserosRoot)
const versionComparison = compareVersions(installed, cached)
logger.debug(VM_TELEMETRY_EVENTS.manifestCompared, {
versionComparison,
installedUpdatedAt: installed?.updatedAt ?? null,
cachedUpdatedAt: cached.updatedAt,
})
const vms = await this.cli.list()
const existing = vms.find((vm) => vm.name === VM_NAME)
let shouldWriteInstalledManifest =
!existing || versionComparison === 'fresh' || versionComparison === 'same'
let branch = !existing
? 'provision-fresh'
: existing.status !== 'Running'
? 'start-existing'
: versionComparison === 'upgrade'
? 'running-upgrade-warn'
: versionComparison === 'downgrade'
? 'running-downgrade-warn'
: 'running-same'
logger.info(VM_TELEMETRY_EVENTS.ensureReadyBranch, {
branch,
existingStatus: existing?.status ?? null,
versionComparison,
})
if (!existing) {
await this.provisionFresh(onLog)
} else {
if (existing.status !== 'Running') {
onLog?.('Starting BrowserOS VM...')
await this.cli.start(VM_NAME)
}
if (
!(await this.isReady()) &&
(await this.needsContainerdReprovision())
) {
branch = 'recreate-legacy-runtime'
shouldWriteInstalledManifest = true
await this.recreateForContainerd(onLog)
} else if (versionComparison === 'upgrade') {
logger.warn(VM_TELEMETRY_EVENTS.upgradeDetected, {
from: installed?.updatedAt ?? null,
to: cached.updatedAt,
})
} else if (versionComparison === 'downgrade') {
logger.warn(VM_TELEMETRY_EVENTS.downgradeDetected, {
from: installed?.updatedAt ?? null,
to: cached.updatedAt,
})
}
}
await this.waitForRootlessNerdctl(this.readinessTimeoutMs)
if (shouldWriteInstalledManifest) {
await writeInstalledManifest(cached, this.deps.browserosRoot)
logger.debug(VM_TELEMETRY_EVENTS.manifestWritten, {
updatedAt: cached.updatedAt,
})
}
logger.info(VM_TELEMETRY_EVENTS.ensureReadyOk, {
durationMs: Date.now() - started,
branch,
})
}
async stopVm(): Promise<void> {
try {
await this.cli.stop(VM_NAME)
} catch (error) {
if (error instanceof LimaCommandError && isAlreadyStopped(error.stderr)) {
return
}
throw error
}
}
async runCommand(
args: string[],
opts?: { onOutput?: LogFn },
): Promise<number> {
return this.cli.shell(VM_NAME, args, {
onStdout: opts?.onOutput,
onStderr: opts?.onOutput,
})
}
async reset(_reason: string): Promise<never> {
throw notImplemented('VmRuntime.reset')
}
async performUpgrade(): Promise<never> {
throw notImplemented('VmRuntime.performUpgrade')
}
async getDefaultGateway(): Promise<string> {
if (this.defaultGateway) return this.defaultGateway
const lines: string[] = []
const exitCode = await this.runCommand(
['ip', '-4', 'route', 'show', 'default'],
{
onOutput: (line) => lines.push(line),
},
)
if (exitCode !== 0) {
throw new VmNotReadyError(
`failed to resolve VM default gateway; ip route exited ${exitCode}`,
)
}
const gateway = parseDefaultGateway(lines.join('\n'))
if (!gateway) {
throw new VmNotReadyError('failed to resolve VM default gateway')
}
this.defaultGateway = gateway
return gateway
}
async isReady(): Promise<boolean> {
return this.isRootlessNerdctlReady()
}
getLimactlPath(): string {
return this.deps.limactlPath
}
private async provisionFresh(onLog?: LogFn): Promise<void> {
this.defaultGateway = null
const yaml = await this.buildLimaYaml()
const yamlPath = join(this.deps.limaHome, `${VM_NAME}.yaml`)
await mkdir(dirname(yamlPath), { recursive: true })
await writeFile(yamlPath, yaml)
logger.info(VM_TELEMETRY_EVENTS.provisionYamlWrite, {
yamlPath,
yamlBytes: yaml.length,
templatePath: this.deps.templatePath,
})
onLog?.('Creating BrowserOS VM...')
logger.info(VM_TELEMETRY_EVENTS.provisionCreateStart, { yamlPath })
const createStarted = Date.now()
await this.cli.create(VM_NAME, yamlPath)
logger.info(VM_TELEMETRY_EVENTS.provisionCreateOk, {
durationMs: Date.now() - createStarted,
})
onLog?.('Starting BrowserOS VM...')
logger.info(VM_TELEMETRY_EVENTS.provisionStartBegin, {})
const startStarted = Date.now()
await this.cli.start(VM_NAME)
logger.info(VM_TELEMETRY_EVENTS.provisionStartOk, {
durationMs: Date.now() - startStarted,
})
}
private async recreateForContainerd(onLog?: LogFn): Promise<void> {
onLog?.('Recreating BrowserOS VM for containerd runtime...')
try {
await this.cli.stop(VM_NAME)
} catch (error) {
if (
!(error instanceof LimaCommandError) ||
!isAlreadyStopped(error.stderr)
) {
throw error
}
}
await this.cli.delete(VM_NAME)
await this.provisionFresh(onLog)
}
private async needsContainerdReprovision(): Promise<boolean> {
const lines: string[] = []
try {
const exitCode = await this.runCommand(
['sh', '-lc', 'cat /etc/browseros-vm-version 2>/dev/null || true'],
{ onOutput: (line) => lines.push(line) },
)
if (exitCode !== 0) return false
} catch (error) {
logger.warn('Failed to inspect BrowserOS VM runtime marker', {
error: error instanceof Error ? error.message : String(error),
})
return false
}
return !lines.some((line) => line.trim() === ROOTLESS_CONTAINERD_MARKER)
}
private async buildLimaYaml(): Promise<string> {
if (!this.deps.templatePath) {
throw new Error(
'BrowserOS VM Lima template path is missing; configure VmRuntime with resourcesDir',
)
}
return renderLimaTemplate(await readFile(this.deps.templatePath, 'utf8'), {
vmStateDir: getVmStateDir(this.deps.browserosRoot),
imageCacheDir: getImageCacheDir(this.deps.browserosRoot),
})
}
private async waitForRootlessNerdctl(timeoutMs: number): Promise<void> {
const started = Date.now()
const deadline = started + timeoutMs
logger.info(VM_TELEMETRY_EVENTS.nerdctlWaitStart, {
timeoutMs,
pollMs: this.readinessPollMs,
})
let pollCount = 0
while (Date.now() < deadline) {
pollCount += 1
if (await this.isReady()) {
logger.info(VM_TELEMETRY_EVENTS.nerdctlWaitOk, {
pollCount,
waitMs: Date.now() - started,
})
return
}
if (pollCount === 1 || pollCount % 10 === 0) {
logger.debug(VM_TELEMETRY_EVENTS.nerdctlWaitPoll, {
pollCount,
elapsedMs: Date.now() - started,
})
}
await Bun.sleep(this.readinessPollMs)
}
logger.error(VM_TELEMETRY_EVENTS.nerdctlWaitTimeout, {
timeoutMs,
pollCount,
})
throw new VmNotReadyError('rootless nerdctl never became ready')
}
private async isRootlessNerdctlReady(): Promise<boolean> {
try {
return (await this.runCommand(['nerdctl', 'info'])) === 0
} catch {
return false
}
}
}
function notImplemented(feature: string): VmError {
return new VmError(
`${feature} is not implemented yet - see WS4 follow-up plan`,
)
}
function isAlreadyStopped(stderr: string): boolean {
const lower = stderr.toLowerCase()
return (
lower.includes('not running') ||
lower.includes('already stopped') ||
lower.includes('not found')
)
}
function parseDefaultGateway(output: string): string | null {
return output.match(/\bdefault\s+via\s+(\d+\.\d+\.\d+\.\d+)\b/)?.[1] ?? null
}

View File

@@ -13,11 +13,8 @@ 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 {
configureOpenClawService,
configureVmRuntime,
getOpenClawService,
} from './api/services/openclaw/openclaw-service'
import { getOpenClawService } from './api/services/openclaw/openclaw-service'
import { configurePodmanRuntime } from './api/services/openclaw/podman-runtime'
import { CdpBackend } from './browser/backends/cdp'
import { Browser } from './browser/browser'
import type { ServerConfig } from './config'
@@ -59,8 +56,9 @@ export class Application {
resourcesDir: path.resolve(this.config.resourcesDir),
})
const resourcesDir = path.resolve(this.config.resourcesDir)
configureVmRuntime({ resourcesDir })
configurePodmanRuntime({
resourcesDir: path.resolve(this.config.resourcesDir),
})
await this.initCoreServices()
if (!this.config.cdpPort) {
@@ -125,10 +123,7 @@ export class Application {
this.logStartupSummary()
startSkillSync()
configureOpenClawService({
browserosServerPort: this.config.serverPort,
resourcesDir,
})
getOpenClawService(this.config.serverPort)
.tryAutoStart()
.catch((err) =>
logger.warn('OpenClaw auto-start failed', {

View File

@@ -1,23 +0,0 @@
import type {
JudgeAuditEnvelope,
MonitoringFinalization,
MonitoringSessionContext,
MonitoringToolCallRecord,
} from './types'
export function buildJudgeAuditEnvelope(input: {
context: MonitoringSessionContext
toolCalls: MonitoringToolCallRecord[]
finalization: MonitoringFinalization | null
}): JudgeAuditEnvelope {
const envelope: JudgeAuditEnvelope = {
run: input.context,
toolCalls: input.toolCalls,
}
if (input.finalization) {
envelope.finalization = input.finalization
}
return envelope
}

View File

@@ -1,18 +0,0 @@
import { logger } from '../lib/logger'
import type { MonitoringToolEndInput, MonitoringToolStartInput } from './types'
export interface ToolExecutionObserver {
onToolStart(input: MonitoringToolStartInput): Promise<void>
onToolEnd(input: MonitoringToolEndInput): Promise<void>
}
export function swallowMonitoringError(
operation: string,
error: unknown,
metadata: Record<string, unknown>,
): void {
logger.warn(`Lazy monitoring ${operation} failed`, {
...metadata,
error: error instanceof Error ? error.message : String(error),
})
}

View File

@@ -1,222 +0,0 @@
import { buildJudgeAuditEnvelope } from './envelope'
import { swallowMonitoringError, type ToolExecutionObserver } from './observer'
import { MonitoringSessionRegistry } from './session-registry'
import { MonitoringStorage } from './storage'
import type {
JudgeAuditEnvelope,
MonitoringFinalization,
MonitoringFinalizeInput,
MonitoringRunSummary,
MonitoringSessionContext,
MonitoringSessionStartInput,
MonitoringToolCallRecord,
MonitoringToolEndInput,
MonitoringToolStartInput,
} from './types'
type ActiveToolCallState = Omit<
MonitoringToolCallRecord,
'finishedAt' | 'durationMs' | 'error' | 'output'
>
export class MonitoringService {
private readonly storage = new MonitoringStorage()
private readonly registry = new MonitoringSessionRegistry()
async startSession(
input: MonitoringSessionStartInput,
): Promise<MonitoringSessionContext> {
const context: MonitoringSessionContext = {
monitoringSessionId: crypto.randomUUID(),
agentId: input.agentId,
sessionKey: input.sessionKey,
originalPrompt: input.originalPrompt,
chatHistory: input.chatHistory,
startedAt: new Date().toISOString(),
source: input.source ?? 'openclaw-agent-chat',
}
await this.storage.writeContext(context)
this.registry.setActive(context.agentId, context.monitoringSessionId)
return context
}
getActiveSessionId(agentId: string): string | undefined {
return this.registry.getActive(agentId)
}
getSingleActiveSession():
| { agentId: string; monitoringSessionId: string }
| undefined {
return this.registry.getSingleActive()
}
clearActiveSession(agentId: string, monitoringSessionId: string): void {
this.registry.clearIfMatches(agentId, monitoringSessionId)
}
createObserver(
monitoringSessionId: string,
agentId: string,
): ToolExecutionObserver {
const activeToolCalls = new Map<string, ActiveToolCallState>()
return {
onToolStart: async (input: MonitoringToolStartInput) => {
try {
activeToolCalls.set(input.toolCallId, {
monitoringSessionId,
agentId,
toolCallId: input.toolCallId,
toolName: input.toolName,
source: input.source,
args: input.args,
startedAt: new Date().toISOString(),
})
} catch (error) {
swallowMonitoringError('tool start recording', error, {
monitoringSessionId,
agentId,
toolCallId: input.toolCallId,
toolName: input.toolName,
})
}
},
onToolEnd: async (input: MonitoringToolEndInput) => {
try {
const active = activeToolCalls.get(input.toolCallId)
if (!active) return
const finishedAt = new Date().toISOString()
const durationMs = Math.max(
0,
new Date(finishedAt).getTime() -
new Date(active.startedAt).getTime(),
)
const record: MonitoringToolCallRecord = {
...active,
finishedAt,
durationMs,
}
if (input.error) {
record.error = input.error
}
if (input.output !== undefined) {
record.output = input.output
}
await this.storage.appendToolCall(record)
activeToolCalls.delete(input.toolCallId)
} catch (error) {
swallowMonitoringError('tool end recording', error, {
monitoringSessionId,
agentId,
toolCallId: input.toolCallId,
})
}
},
}
}
async finalizeSession(
input: MonitoringFinalizeInput,
): Promise<JudgeAuditEnvelope | null> {
const context = await this.storage.readContext(input.monitoringSessionId)
if (!context) {
return null
}
const finalization: MonitoringFinalization = {
monitoringSessionId: input.monitoringSessionId,
agentId: input.agentId,
sessionKey: input.sessionKey,
status: input.status,
finalizedAt: new Date().toISOString(),
}
if (input.finalAssistantMessage) {
finalization.finalAssistantMessage = input.finalAssistantMessage
}
if (input.error) {
finalization.error = input.error
}
await this.storage.writeFinalization(finalization)
this.registry.clearIfMatches(input.agentId, input.monitoringSessionId)
return this.buildAndPersistEnvelope(input.monitoringSessionId)
}
async getRunEnvelope(runId: string): Promise<JudgeAuditEnvelope | null> {
const context = await this.storage.readContext(runId)
if (!context) return null
const toolCalls = await this.storage.readToolCalls(runId)
const finalization = await this.storage.readFinalization(runId)
return buildJudgeAuditEnvelope({
context,
toolCalls,
finalization,
})
}
async listRuns(limit = 50): Promise<MonitoringRunSummary[]> {
const runIds = (await this.storage.listRunIds()).slice(0, limit)
const summaries = await Promise.all(
runIds.map(async (runId) => {
const context = await this.storage.readContext(runId)
if (!context) return null
const [toolCalls, finalization] = await Promise.all([
this.storage.readToolCalls(runId),
this.storage.readFinalization(runId),
])
const summary: MonitoringRunSummary = {
monitoringSessionId: context.monitoringSessionId,
agentId: context.agentId,
sessionKey: context.sessionKey,
originalPrompt: context.originalPrompt,
startedAt: context.startedAt,
source: context.source,
toolCallCount: toolCalls.length,
}
if (finalization) {
summary.finalization = {
status: finalization.status,
finalizedAt: finalization.finalizedAt,
error: finalization.error,
}
}
return summary
}),
)
return summaries.filter((summary): summary is MonitoringRunSummary =>
Boolean(summary),
)
}
private async buildAndPersistEnvelope(
runId: string,
): Promise<JudgeAuditEnvelope | null> {
const envelope = await this.getRunEnvelope(runId)
if (!envelope) return null
await this.storage.writeAuditEnvelope(runId, envelope)
return envelope
}
}
let monitoringService: MonitoringService | null = null
export function getMonitoringService(): MonitoringService {
if (!monitoringService) {
monitoringService = new MonitoringService()
}
return monitoringService
}

View File

@@ -1,34 +0,0 @@
export class MonitoringSessionRegistry {
private readonly activeSessionsByAgent = new Map<string, string>()
setActive(agentId: string, monitoringSessionId: string): void {
this.activeSessionsByAgent.set(agentId, monitoringSessionId)
}
getActive(agentId: string): string | undefined {
return this.activeSessionsByAgent.get(agentId)
}
getSingleActive():
| { agentId: string; monitoringSessionId: string }
| undefined {
if (this.activeSessionsByAgent.size !== 1) {
return undefined
}
const [agentId, monitoringSessionId] =
this.activeSessionsByAgent.entries().next().value ?? []
if (!agentId || !monitoringSessionId) {
return undefined
}
return { agentId, monitoringSessionId }
}
clearIfMatches(agentId: string, monitoringSessionId: string): void {
if (this.activeSessionsByAgent.get(agentId) !== monitoringSessionId) {
return
}
this.activeSessionsByAgent.delete(agentId)
}
}

View File

@@ -1,175 +0,0 @@
import {
appendFile,
mkdir,
readdir,
readFile,
stat,
writeFile,
} from 'node:fs/promises'
import { join } from 'node:path'
import {
getLazyMonitoringRunDir,
getLazyMonitoringRunsDir,
} from '../lib/browseros-dir'
import type {
MonitoringFinalization,
MonitoringSessionContext,
MonitoringToolCallRecord,
} from './types'
const CONTEXT_FILE_NAME = 'context.json'
const TOOL_CALLS_FILE_NAME = 'tool-calls.jsonl'
const FINALIZATION_FILE_NAME = 'finalization.json'
const AUDIT_ENVELOPE_FILE_NAME = 'audit-envelope.json'
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
export class InvalidMonitoringRunIdError extends Error {
constructor(runId: string) {
super(`Invalid monitoring run id: ${runId}`)
this.name = 'InvalidMonitoringRunIdError'
}
}
export function isValidMonitoringRunId(runId: string): boolean {
return UUID_PATTERN.test(runId)
}
function assertValidMonitoringRunId(runId: string): void {
if (!isValidMonitoringRunId(runId)) {
throw new InvalidMonitoringRunIdError(runId)
}
}
export class MonitoringStorage {
async writeContext(context: MonitoringSessionContext): Promise<void> {
await this.ensureRunDir(context.monitoringSessionId)
await writeFile(
this.getContextPath(context.monitoringSessionId),
`${JSON.stringify(context, null, 2)}\n`,
)
}
async appendToolCall(record: MonitoringToolCallRecord): Promise<void> {
await this.ensureRunDir(record.monitoringSessionId)
await appendFile(
this.getToolCallsPath(record.monitoringSessionId),
`${JSON.stringify(record)}\n`,
)
}
async writeFinalization(finalization: MonitoringFinalization): Promise<void> {
await this.ensureRunDir(finalization.monitoringSessionId)
await writeFile(
this.getFinalizationPath(finalization.monitoringSessionId),
`${JSON.stringify(finalization, null, 2)}\n`,
)
}
async writeAuditEnvelope(runId: string, envelope: unknown): Promise<void> {
await this.ensureRunDir(runId)
await writeFile(
this.getAuditEnvelopePath(runId),
`${JSON.stringify(envelope, null, 2)}\n`,
)
}
async readContext(runId: string): Promise<MonitoringSessionContext | null> {
return this.readJsonFile<MonitoringSessionContext>(
this.getContextPath(runId),
)
}
async readFinalization(
runId: string,
): Promise<MonitoringFinalization | null> {
return this.readJsonFile<MonitoringFinalization>(
this.getFinalizationPath(runId),
)
}
async readToolCalls(runId: string): Promise<MonitoringToolCallRecord[]> {
try {
const content = await readFile(this.getToolCallsPath(runId), 'utf8')
return content
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.flatMap((line) => {
try {
return [JSON.parse(line) as MonitoringToolCallRecord]
} catch {
return []
}
})
} catch {
return []
}
}
async listRunIds(): Promise<string[]> {
try {
const entries = await readdir(getLazyMonitoringRunsDir(), {
withFileTypes: true,
})
const directories = entries.filter(
(entry) => entry.isDirectory() && isValidMonitoringRunId(entry.name),
)
const runStats = await Promise.all(
directories.map(async (entry) => ({
runId: entry.name,
mtimeMs: await this.getDirectoryMtimeMs(entry.name),
})),
)
return runStats
.sort((a, b) => b.mtimeMs - a.mtimeMs)
.map((entry) => entry.runId)
} catch {
return []
}
}
private async ensureRunDir(runId: string): Promise<void> {
assertValidMonitoringRunId(runId)
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
await mkdir(getLazyMonitoringRunDir(runId), { recursive: true })
}
private async getDirectoryMtimeMs(runId: string): Promise<number> {
try {
const info = await stat(getLazyMonitoringRunDir(runId))
return info.mtimeMs
} catch {
return 0
}
}
private async readJsonFile<T>(path: string): Promise<T | null> {
try {
const content = await readFile(path, 'utf8')
return JSON.parse(content) as T
} catch {
return null
}
}
private getContextPath(runId: string): string {
assertValidMonitoringRunId(runId)
return join(getLazyMonitoringRunDir(runId), CONTEXT_FILE_NAME)
}
private getToolCallsPath(runId: string): string {
assertValidMonitoringRunId(runId)
return join(getLazyMonitoringRunDir(runId), TOOL_CALLS_FILE_NAME)
}
private getFinalizationPath(runId: string): string {
assertValidMonitoringRunId(runId)
return join(getLazyMonitoringRunDir(runId), FINALIZATION_FILE_NAME)
}
private getAuditEnvelopePath(runId: string): string {
assertValidMonitoringRunId(runId)
return join(getLazyMonitoringRunDir(runId), AUDIT_ENVELOPE_FILE_NAME)
}
}

View File

@@ -1,92 +0,0 @@
export type MonitoringChatTurnRole = 'user' | 'assistant'
export interface MonitoringChatTurn {
role: MonitoringChatTurnRole
content: string
}
export interface MonitoringSessionContext {
monitoringSessionId: string
agentId: string
sessionKey: string
originalPrompt: string
chatHistory: MonitoringChatTurn[]
startedAt: string
source: 'openclaw-agent-chat' | 'debug'
}
export type MonitoringToolCallSource = 'browser-tool' | 'klavis-tool'
export interface MonitoringToolCallRecord {
monitoringSessionId: string
agentId: string
toolCallId: string
toolName: string
source: MonitoringToolCallSource
args: unknown
output?: unknown
error?: string
startedAt: string
finishedAt?: string
durationMs?: number
}
export interface MonitoringFinalization {
monitoringSessionId: string
agentId: string
sessionKey: string
status: 'completed' | 'failed' | 'aborted' | 'incomplete'
finalAssistantMessage?: string
error?: string
finalizedAt: string
}
export interface JudgeAuditEnvelope {
run: MonitoringSessionContext
toolCalls: MonitoringToolCallRecord[]
finalization?: MonitoringFinalization
}
export interface MonitoringRunSummary {
monitoringSessionId: string
agentId: string
sessionKey: string
originalPrompt: string
startedAt: string
source: MonitoringSessionContext['source']
toolCallCount: number
finalization?: Pick<
MonitoringFinalization,
'status' | 'finalizedAt' | 'error'
>
}
export interface MonitoringSessionStartInput {
agentId: string
sessionKey: string
originalPrompt: string
chatHistory: MonitoringChatTurn[]
source?: MonitoringSessionContext['source']
}
export interface MonitoringToolStartInput {
toolCallId: string
toolName: string
source: MonitoringToolCallSource
args: unknown
}
export interface MonitoringToolEndInput {
toolCallId: string
output?: unknown
error?: string
}
export interface MonitoringFinalizeInput {
monitoringSessionId: string
agentId: string
sessionKey: string
status: MonitoringFinalization['status']
finalAssistantMessage?: string
error?: string
}

View File

@@ -1,50 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { chmod, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
export interface FakeLimactlResponse {
stdout?: string
stderr?: string
exit?: number
}
export async function fakeLimactl(
canned: Record<string, FakeLimactlResponse>,
logPath?: string,
): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'fake-limactl-'))
const path = join(dir, 'limactl')
const limaHomeExpansion = '$' + '{LIMA_HOME-}'
const cases = Object.entries(canned)
.map(([command, response]) =>
[
` ${JSON.stringify(command)})`,
` echo "ARGS:$*" >> "${logPath ?? '/dev/null'}"`,
` echo "LIMA_HOME:${limaHomeExpansion}" >> "${logPath ?? '/dev/null'}"`,
` printf %b ${JSON.stringify(response.stdout ?? '')}`,
` printf %b ${JSON.stringify(response.stderr ?? '')} >&2`,
` exit ${response.exit ?? 0}`,
' ;;',
].join('\n'),
)
.join('\n')
const body = `#!/usr/bin/env bash
set -u
case "$1" in
${cases}
*)
echo "ARGS:$*" >> "${logPath ?? '/dev/null'}"
echo "unexpected subcommand: $1" >&2
exit 99
;;
esac
`
await writeFile(path, body)
await chmod(path, 0o755)
return path
}

View File

@@ -1,32 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { chmod, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
export interface FakeSshResponse {
stdout?: string
stderr?: string
exit?: number
}
export async function fakeSsh(
response: FakeSshResponse = {},
logPath?: string,
): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'fake-ssh-'))
const path = join(dir, 'ssh')
const body = `#!/usr/bin/env bash
set -u
echo "ARGS:$*" >> "${logPath ?? '/dev/null'}"
printf %b ${JSON.stringify(response.stdout ?? '')}
printf %b ${JSON.stringify(response.stderr ?? '')} >&2
exit ${response.exit ?? 0}
`
await writeFile(path, body)
await chmod(path, 0o755)
return path
}

View File

@@ -1,166 +0,0 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, readdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
const projectRoot = resolve(import.meta.dir, '..', '..')
const testsRoot = resolve(projectRoot, 'tests')
const cleanupScript = resolve(testsRoot, '__helpers__/cleanup.sh')
const testPreloadPath = './tests/__helpers__/test-env.ts'
const preferredDirectoryGroups = [
'agent',
'api',
'skills',
'tools',
'browser',
'sdk',
]
const ignoredDirectories = new Set(['__fixtures__', '__helpers__'])
const rootGroupExclusions = new Set(['server.integration.test.ts'])
const testFilePattern = /\.(test|spec)\.[cm]?[jt]sx?$/
function compareGroupNames(left: string, right: string): number {
const leftIndex = preferredDirectoryGroups.indexOf(left)
const rightIndex = preferredDirectoryGroups.indexOf(right)
const leftRank =
leftIndex === -1 ? preferredDirectoryGroups.length : leftIndex
const rightRank =
rightIndex === -1 ? preferredDirectoryGroups.length : rightIndex
if (leftRank !== rightRank) {
return leftRank - rightRank
}
return left.localeCompare(right)
}
function listDirectoryGroups(): string[] {
return readdirSync(testsRoot, { withFileTypes: true })
.filter(
(entry) => entry.isDirectory() && !ignoredDirectories.has(entry.name),
)
.map((entry) => entry.name)
.sort(compareGroupNames)
}
function listRootTestTargets(): string[] {
return readdirSync(testsRoot, { withFileTypes: true })
.filter((entry) => entry.isFile() && testFilePattern.test(entry.name))
.filter((entry) => !rootGroupExclusions.has(entry.name))
.map((entry) => `./tests/${entry.name}`)
.sort((left, right) => left.localeCompare(right))
}
function listAllGroups(): string[] {
const groups = [...listDirectoryGroups()]
if (existsSync(resolve(testsRoot, 'server.integration.test.ts'))) {
groups.push('integration')
}
if (listRootTestTargets().length > 0) {
groups.push('root')
}
return groups
}
function listAvailableGroupNames(): string[] {
return ['all', 'core', 'cdp', ...listAllGroups()].sort((left, right) =>
left.localeCompare(right),
)
}
function getCompositeGroupMembers(group: string): string[] | null {
if (group === 'all') {
return listAllGroups()
}
if (group === 'core') {
return ['agent', 'api', 'skills', 'root']
}
return null
}
function getAtomicGroupTargets(group: string): string[] {
if (group === 'cdp') {
return getAtomicGroupTargets('browser')
}
if (group === 'integration') {
return existsSync(resolve(testsRoot, 'server.integration.test.ts'))
? ['./tests/server.integration.test.ts']
: []
}
if (group === 'root') {
return listRootTestTargets()
}
if (existsSync(resolve(testsRoot, group))) {
return [`./tests/${group}`]
}
return []
}
function runCommand(cmd: string[], label: string): number {
console.log(`\n==> ${label}`)
const result = spawnSync(cmd[0], cmd.slice(1), {
cwd: projectRoot,
env: withTestEnv(process.env),
stdio: 'inherit',
})
if (result.error) {
throw result.error
}
return result.status ?? 1
}
export function withTestEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
if (env.NODE_ENV) return env
return { ...env, NODE_ENV: 'test' }
}
export function buildTestCommand(
targets: string[],
junitPath?: string,
): string[] {
const cmd = [
process.execPath,
'--env-file=.env.development',
'test',
`--preload=${testPreloadPath}`,
]
if (junitPath) {
const outputPath = resolve(projectRoot, junitPath)
mkdirSync(dirname(outputPath), { recursive: true })
cmd.push('--reporter=junit', `--reporter-outfile=${outputPath}`)
}
cmd.push(...targets)
return cmd
}
function runAtomicGroup(group: string): number {
const targets = getAtomicGroupTargets(group)
if (targets.length === 0) {
throw new Error(
`Unknown test group "${group}". Available groups: ${listAvailableGroupNames().join(', ')}`,
)
}
runCommand(['bash', cleanupScript], `Cleaning up test resources for ${group}`)
const junitPath = process.env.BROWSEROS_JUNIT_PATH?.trim()
const cmd = buildTestCommand(targets, junitPath)
return runCommand(cmd, `Running ${group} tests`)
}
function runGroup(group: string): number {
const compositeMembers = getCompositeGroupMembers(group)
if (compositeMembers) {
let exitCode = 0
for (const member of compositeMembers) {
const status = runGroup(member)
if (status !== 0 && exitCode === 0) {
exitCode = status
}
}
return exitCode
}
return runAtomicGroup(group)
}
if (import.meta.main) {
const requestedGroup = process.argv[2] ?? 'all'
process.exit(runGroup(requestedGroup))
}

View File

@@ -1,6 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
process.env.NODE_ENV = 'test'

View File

@@ -1168,9 +1168,8 @@ describe('compaction E2E — pruning and output reduction', () => {
{ role: 'user', content: 'x'.repeat(3000) },
]
const estimated = estimateTokensForThreshold(messages, config)
expect(estimated).toBe(
Math.ceil(1000 * config.safetyMultiplier) + config.fixedOverhead,
)
// 3000 chars / 3 = 1000 tokens, * 1.3 = 1300, + 12000 = 13300
expect(estimated).toBe(Math.ceil(1000 * 1.3) + 12_000)
})
})

View File

@@ -19,7 +19,7 @@ afterEach(() => {
})
describe('createKlavisRoutes', () => {
it('normalizes string integrations into unauthenticated entries', async () => {
it('normalizes string integrations into authenticated entries', async () => {
globalThis.fetch = (async () =>
Response.json({
integrations: ['Google Docs', 'Slack'],
@@ -32,8 +32,8 @@ describe('createKlavisRoutes', () => {
assert.strictEqual(response.status, 200)
assert.deepStrictEqual(body, {
integrations: [
{ name: 'Google Docs', is_authenticated: false },
{ name: 'Slack', is_authenticated: false },
{ name: 'Google Docs', is_authenticated: true },
{ name: 'Slack', is_authenticated: true },
],
count: 2,
})

View File

@@ -1,437 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it, mock } from 'bun:test'
import { OpenClawSessionNotFoundError } from '../../../src/api/services/openclaw/errors'
import { UnsupportedOpenClawProviderError } from '../../../src/api/services/openclaw/openclaw-provider-map'
describe('createOpenClawRoutes', () => {
afterEach(() => {
mock.restore()
})
it('preserves BrowserOS SSE framing, session headers, and defaults chat history for chat', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const chatStream = mock(
async () =>
new ReadableStream({
start(controller) {
controller.enqueue({
type: 'text-delta',
data: { text: 'Hello' },
})
controller.enqueue({
type: 'done',
data: { text: 'Hello' },
})
controller.close()
},
}),
)
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () =>
({
chatStream,
}) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/agents/research/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: 'hi',
sessionKey: 'session-123',
}),
})
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toContain('text/event-stream')
expect(response.headers.get('X-Session-Key')).toBe('session-123')
expect(chatStream).toHaveBeenCalledWith('research', 'session-123', 'hi', [])
expect(await response.text()).toBe(
'data: {"type":"text-delta","data":{"text":"Hello"}}\n\n' +
'data: {"type":"done","data":{"text":"Hello"}}\n\n' +
'data: [DONE]\n\n',
)
})
it('passes prior chat history through to the OpenClaw chat stream', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const chatStream = mock(
async () =>
new ReadableStream({
start(controller) {
controller.enqueue({
type: 'done',
data: { text: 'Done' },
})
controller.close()
},
}),
)
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () =>
({
chatStream,
}) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const history = [
{ role: 'user' as const, content: 'Find my open tasks' },
{ role: 'assistant' as const, content: 'I am checking Linear now.' },
]
const response = await route.request('/agents/research/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: 'Summarize what is blocked',
sessionKey: 'session-456',
history,
}),
})
expect(response.status).toBe(200)
expect(chatStream).toHaveBeenCalledWith(
'research',
'session-456',
'Summarize what is blocked',
history,
)
})
it('rejects concurrent monitored chat requests for the same agent', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const actualMonitoringService = await import(
'../../../src/monitoring/service'
)
const chatStream = mock(async () => new ReadableStream())
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () =>
({
chatStream,
}) as never,
}))
mock.module('../../../src/monitoring/service', () => ({
...actualMonitoringService,
getMonitoringService: () =>
({
getActiveSessionId: (agentId: string) =>
agentId === 'research' ? 'existing-run' : undefined,
}) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/agents/research/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: 'hi',
sessionKey: 'session-789',
}),
})
expect(response.status).toBe(409)
expect(chatStream).not.toHaveBeenCalled()
expect(await response.json()).toEqual({
error:
'A monitored chat session is already active for this agent. Wait for it to finish before starting another.',
})
})
it('returns 400 for unsupported provider payloads', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const updateProviderKeys = mock(async () => {
throw new UnsupportedOpenClawProviderError('google')
})
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () =>
({
updateProviderKeys,
}) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/providers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerType: 'google',
apiKey: 'google-key',
}),
})
expect(response.status).toBe(400)
expect(updateProviderKeys).toHaveBeenCalledWith({
providerType: 'google',
apiKey: 'google-key',
})
expect(await response.json()).toEqual({
error: 'Unsupported OpenClaw provider: google',
})
})
it('returns a non-restarting response when only the default model changes', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const updateProviderKeys = mock(async () => ({
restarted: false,
modelUpdated: true,
}))
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () =>
({
updateProviderKeys,
}) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/providers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerType: 'openai',
apiKey: 'sk-test',
modelId: 'gpt-5.4-mini',
}),
})
expect(response.status).toBe(200)
expect(updateProviderKeys).toHaveBeenCalledWith({
providerType: 'openai',
apiKey: 'sk-test',
modelId: 'gpt-5.4-mini',
})
expect(await response.json()).toEqual({
status: 'updated',
message: 'Provider updated without a restart',
})
})
it('does not expose a roles route', async () => {
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/roles')
expect(response.status).toBe(404)
})
it('ignores role fields when creating agents', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const createAgent = mock(async () => ({
agentId: 'research',
name: 'research',
workspace: '/home/node/.openclaw/workspace-research',
}))
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () =>
({
createAgent,
}) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'research',
roleId: 'chief-of-staff',
customRole: {
name: 'Ignored',
shortDescription: 'Ignored',
longDescription: 'Ignored',
recommendedApps: [],
boundaries: [],
},
providerType: 'openai',
apiKey: 'sk-test',
modelId: 'gpt-5.4-mini',
}),
})
expect(response.status).toBe(201)
expect(createAgent).toHaveBeenCalledWith({
name: 'research',
providerType: 'openai',
providerName: undefined,
baseUrl: undefined,
apiKey: 'sk-test',
modelId: 'gpt-5.4-mini',
})
})
it('returns JSON history from the session history route and forwards query params', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const getSessionHistory = mock(async () => ({
sessionKey: 'agent:main:main',
messages: [{ role: 'user', content: 'hi', messageSeq: 1 }],
cursor: null,
hasMore: false,
}))
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () => ({ getSessionHistory }) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request(
'/session/agent%3Amain%3Amain/history?limit=25&cursor=next',
)
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toContain('application/json')
expect(getSessionHistory).toHaveBeenCalledWith('agent:main:main', {
limit: 25,
cursor: 'next',
})
expect(await response.json()).toEqual({
sessionKey: 'agent:main:main',
messages: [{ role: 'user', content: 'hi', messageSeq: 1 }],
cursor: null,
hasMore: false,
})
})
it('returns 404 when the service reports a missing session', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const getSessionHistory = mock(async () => {
throw new OpenClawSessionNotFoundError('missing')
})
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () => ({ getSessionHistory }) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/session/missing/history')
expect(response.status).toBe(404)
expect(await response.json()).toEqual({
error: 'OpenClaw session not found: missing',
})
})
it('streams named SSE frames when Accept: text/event-stream', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const streamSessionHistory = mock(
async () =>
new ReadableStream({
start(controller) {
controller.enqueue({
type: 'history',
data: {
sessionKey: 'k',
messages: [],
cursor: null,
hasMore: false,
},
})
controller.enqueue({
type: 'message',
data: {
sessionKey: 'k',
messageSeq: 2,
message: { role: 'assistant', content: 'hi', messageSeq: 2 },
},
})
controller.close()
},
}),
)
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () => ({ streamSessionHistory }) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/session/k/history', {
headers: { Accept: 'text/event-stream' },
})
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toContain('text/event-stream')
expect(response.headers.get('X-Session-Key')).toBe('k')
expect(streamSessionHistory).toHaveBeenCalledTimes(1)
expect(streamSessionHistory.mock.calls[0]?.[0]).toBe('k')
expect(await response.text()).toBe(
'event: history\ndata: {"sessionKey":"k","messages":[],"cursor":null,"hasMore":false}\n\n' +
'event: message\ndata: {"sessionKey":"k","messageSeq":2,"message":{"role":"assistant","content":"hi","messageSeq":2}}\n\n',
)
})
})

View File

@@ -4,13 +4,11 @@
*/
import { describe, expect, it } from 'bun:test'
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
import {
parseTerminalClientMessage,
serializeTerminalServerMessage,
} from '../../../src/api/services/terminal/terminal-protocol'
import {
buildTerminalEnv,
buildTerminalExecCommand,
TERMINAL_HOME_DIR,
} from '../../../src/api/services/terminal/terminal-session'
@@ -51,35 +49,21 @@ describe('terminal protocol', () => {
).toBe('{"type":"output","data":"hello"}')
})
it('builds a limactl shell command rooted in the container home dir', () => {
it('builds a podman exec command rooted in the container home dir', () => {
expect(
buildTerminalExecCommand(
'limactl',
'browseros-vm',
OPENCLAW_GATEWAY_CONTAINER_NAME,
'podman',
'browseros-openclaw-openclaw-gateway-1',
TERMINAL_HOME_DIR,
),
).toEqual([
'limactl',
'shell',
'browseros-vm',
'--',
'nerdctl',
'podman',
'exec',
'-it',
'-w',
'/home/node/.openclaw',
OPENCLAW_GATEWAY_CONTAINER_NAME,
'browseros-openclaw-openclaw-gateway-1',
'/bin/sh',
])
})
it('sets LIMA_HOME for terminal limactl sessions', () => {
expect(buildTerminalEnv('/tmp/browseros-lima')).toEqual(
expect.objectContaining({
LIMA_HOME: '/tmp/browseros-lima',
TERM: 'xterm-256color',
}),
)
})
})

View File

@@ -1,116 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import {
buildContainerRuntime,
migrateLegacyOpenClawDir,
} from '../../../../src/api/services/openclaw/container-runtime-factory'
import { logger } from '../../../../src/lib/logger'
describe('container-runtime factory', () => {
let root: string
let resourcesDir: string
let originalNodeEnv: string | undefined
beforeEach(async () => {
root = await mkdtemp('/tmp/openclaw-runtime-factory-')
resourcesDir = join(root, 'resources')
await mkdir(join(resourcesDir, 'bin', 'third_party', 'lima'), {
recursive: true,
})
await mkdir(join(resourcesDir, 'vm'), { recursive: true })
await writeFile(
join(resourcesDir, 'bin', 'third_party', 'lima', 'limactl'),
'#!/bin/sh\n',
)
await writeFile(
join(resourcesDir, 'vm', 'browseros-vm.yaml'),
'mounts: []\n',
)
originalNodeEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'production'
})
afterEach(async () => {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = originalNodeEnv
}
await rm(root, { recursive: true, force: true })
})
it('rejects non-macOS platforms', () => {
expect(() =>
buildContainerRuntime({
resourcesDir,
projectDir: join(root, 'project'),
browserosRoot: root,
platform: 'linux',
}),
).toThrow('supports macOS only')
})
it('returns a disabled runtime on non-macOS platforms in test mode', async () => {
process.env.NODE_ENV = 'test'
const runtime = buildContainerRuntime({
resourcesDir,
projectDir: join(root, 'project'),
browserosRoot: root,
platform: 'linux',
})
await expect(runtime.getMachineStatus()).resolves.toEqual({
initialized: false,
running: false,
})
await expect(runtime.ensureReady()).rejects.toThrow('supports macOS only')
await expect(runtime.stopVm()).resolves.toBeUndefined()
})
it('migrates legacy OpenClaw state into the VM state directory', async () => {
const legacyFile = join(root, 'openclaw', '.openclaw', 'openclaw.json')
await mkdir(dirname(legacyFile), { recursive: true })
await writeFile(legacyFile, '{"ok":true}\n')
await migrateLegacyOpenClawDir(root)
await expect(
readFile(
join(root, 'vm', 'openclaw', '.openclaw', 'openclaw.json'),
'utf8',
),
).resolves.toBe('{"ok":true}\n')
await expect(readFile(legacyFile, 'utf8')).resolves.toBe('{"ok":true}\n')
})
it('leaves both directories in place when new OpenClaw state already exists', async () => {
const legacyFile = join(root, 'openclaw', 'legacy.txt')
const newFile = join(root, 'vm', 'openclaw', 'new.txt')
await mkdir(dirname(legacyFile), { recursive: true })
await mkdir(dirname(newFile), { recursive: true })
await writeFile(legacyFile, 'legacy')
await writeFile(newFile, 'new')
const originalWarn = logger.warn
const warnings: string[] = []
logger.warn = (message) => warnings.push(message)
try {
await migrateLegacyOpenClawDir(root)
} finally {
logger.warn = originalWarn
}
await expect(readFile(legacyFile, 'utf8')).resolves.toBe('legacy')
await expect(readFile(newFile, 'utf8')).resolves.toBe('new')
expect(warnings).toContain(
'OpenClaw legacy and VM state directories both exist',
)
})
})

View File

@@ -1,176 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it, mock } from 'bun:test'
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
import { ContainerRuntime } from '../../../../src/api/services/openclaw/container-runtime'
const PROJECT_DIR = '/tmp/openclaw'
const defaultSpec = {
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
hostPort: 18789,
hostHome: '/Users/me/.browseros/vm/openclaw',
envFilePath: '/Users/me/.browseros/vm/openclaw/.openclaw/.env',
gatewayToken: 'token-123',
timezone: 'America/Los_Angeles',
}
describe('ContainerRuntime', () => {
it('starts the gateway by loading the image, creating, and starting a container', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.startGateway(defaultSpec)
expect(deps.shell.removeContainer).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
{ force: true },
undefined,
)
expect(deps.loader.ensureImageLoaded).toHaveBeenCalledWith(
defaultSpec.image,
undefined,
)
expect(deps.shell.createContainer).toHaveBeenCalledWith(
expect.objectContaining({
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
image: defaultSpec.image,
restart: 'unless-stopped',
ports: [
{
hostIp: '127.0.0.1',
hostPort: 18789,
containerPort: 18789,
},
],
envFile: '/mnt/browseros/vm/openclaw/.openclaw/.env',
mounts: [
{
source: '/mnt/browseros/vm/openclaw',
target: '/home/node',
},
],
addHosts: ['host.containers.internal:192.168.5.2'],
}),
undefined,
)
expect(deps.shell.startContainer).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
)
})
it('delegates ensureReady and stopVm to VmRuntime', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.ensureReady()
await runtime.stopVm()
expect(deps.vm.ensureReady).toHaveBeenCalled()
expect(deps.vm.getDefaultGateway).toHaveBeenCalled()
expect(deps.vm.stopVm).toHaveBeenCalled()
})
it('runs setup commands with guest paths', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
await runtime.runGatewaySetupCommand(
['node', 'dist/index.js', 'agents', 'list', '--json'],
defaultSpec,
)
expect(deps.shell.runCommand).toHaveBeenCalledWith(
expect.arrayContaining([
'create',
'--name',
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
'--env-file',
'/mnt/browseros/vm/openclaw/.openclaw/.env',
'-v',
'/mnt/browseros/vm/openclaw:/home/node',
'--add-host',
'host.containers.internal:192.168.5.2',
]),
undefined,
)
expect(deps.shell.runCommand).toHaveBeenCalledWith(
['start', '-a', `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`],
undefined,
)
expect(deps.shell.removeContainer).toHaveBeenCalledWith(
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
{ force: true },
undefined,
)
})
it('tails and fetches gateway logs through the new transport', async () => {
const deps = createDeps()
const runtime = new ContainerRuntime({
vm: deps.vm,
shell: deps.shell,
loader: deps.loader,
projectDir: PROJECT_DIR,
})
const stop = runtime.tailGatewayLogs(() => {})
const logs = await runtime.getGatewayLogs(10)
stop()
expect(deps.shell.tailLogs).toHaveBeenCalledWith(
OPENCLAW_GATEWAY_CONTAINER_NAME,
expect.any(Function),
)
expect(deps.shell.runCommand).toHaveBeenCalledWith(
['logs', '-n', '10', OPENCLAW_GATEWAY_CONTAINER_NAME],
expect.any(Function),
)
expect(logs).toEqual(['log line'])
})
})
function createDeps() {
return {
vm: {
ensureReady: mock(async () => {}),
getDefaultGateway: mock(async () => '192.168.5.2'),
stopVm: mock(async () => {}),
isReady: mock(async () => true),
},
shell: {
createContainer: mock(async () => {}),
startContainer: mock(async () => {}),
stopContainer: mock(async () => {}),
removeContainer: mock(async () => {}),
exec: mock(async () => 0),
runCommand: mock(
async (_args: string[], onLog?: (line: string) => void) => {
onLog?.('log line')
return { exitCode: 0, stdout: 'log line\n', stderr: '' }
},
),
tailLogs: mock(() => () => {}),
},
loader: {
ensureImageLoaded: mock(async () => {}),
},
}
}

View File

@@ -1,412 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it, mock } from 'bun:test'
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
import { OpenClawCliClient } from '../../../../src/api/services/openclaw/openclaw-cli-client'
describe('OpenClawCliClient', () => {
it('passes real non-interactive onboarding flags through to the upstream cli', async () => {
const execInContainer = mock(async (command: string[]) => {
expect(command).toEqual([
'node',
'dist/index.js',
'onboard',
'--non-interactive',
'--mode',
'local',
'--auth-choice',
'skip',
'--gateway-auth',
'token',
'--gateway-port',
'18789',
'--gateway-bind',
'lan',
'--no-install-daemon',
'--skip-health',
'--accept-risk',
])
return 0
})
const client = new OpenClawCliClient({ execInContainer })
await client.runOnboard({
nonInteractive: true,
mode: 'local',
authChoice: 'skip',
gatewayAuth: 'token',
gatewayPort: 18789,
gatewayBind: 'lan',
installDaemon: false,
skipHealth: true,
acceptRisk: true,
})
})
it('uses batch mode for grouped config writes', async () => {
const execInContainer = mock(async (command: string[]) => {
expect(command).toEqual([
'node',
'dist/index.js',
'config',
'set',
'--batch-json',
'[{"path":"gateway.mode","value":"local"},{"path":"gateway.http.endpoints.chatCompletions.enabled","value":true}]',
])
return 0
})
const client = new OpenClawCliClient({ execInContainer })
await client.setConfigBatch([
{
path: 'gateway.mode',
value: 'local',
},
{
path: 'gateway.http.endpoints.chatCompletions.enabled',
value: true,
},
])
})
it('runs upstream CLI commands without appending a gateway token flag', async () => {
const execInContainer = mock(
async (command: string[], onLog?: (line: string) => void) => {
if (command[2] === 'agents' && command[3] === 'list') {
onLog?.(
JSON.stringify([
{
id: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
model: 'openrouter/anthropic/claude-sonnet-4.5',
},
]),
)
}
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const agents = await client.listAgents()
expect(execInContainer.mock.calls[0]?.[0]).toEqual([
'node',
'dist/index.js',
'agents',
'list',
'--json',
])
expect(agents[0]?.model).toBe('openrouter/anthropic/claude-sonnet-4.5')
})
it('derives the workspace when creating an agent', async () => {
let callIndex = 0
const execInContainer = mock(
async (command: string[], onLog?: (line: string) => void) => {
callIndex += 1
if (callIndex === 1) {
expect(command).toEqual([
'node',
'dist/index.js',
'agents',
'add',
'research',
'--workspace',
`${OPENCLAW_CONTAINER_HOME}/workspace-research`,
'--model',
'openai/gpt-5.4-mini',
'--non-interactive',
'--json',
])
return 0
}
onLog?.(
JSON.stringify([
{
id: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
},
{
id: 'research',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-research`,
model: 'openai/gpt-5.4-mini',
},
]),
)
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const agent = await client.createAgent({
name: 'research',
model: 'openai/gpt-5.4-mini',
})
expect(execInContainer).toHaveBeenCalledTimes(2)
expect(agent).toEqual({
agentId: 'research',
name: 'research',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace-research`,
model: 'openai/gpt-5.4-mini',
})
})
it('parses agent lists from mixed log and JSON output', async () => {
const execInContainer = mock(
async (_command: string[], onLog?: (line: string) => void) => {
onLog?.('starting agent listing')
onLog?.(
JSON.stringify([
{
id: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
},
]),
)
onLog?.('done')
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const agents = await client.listAgents()
expect(agents).toEqual([
{
agentId: 'main',
name: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
},
])
})
it('parses pretty-printed JSON surrounded by logs', async () => {
const execInContainer = mock(
async (_command: string[], onLog?: (line: string) => void) => {
onLog?.('starting agent listing')
onLog?.('[')
onLog?.(' {')
onLog?.(' "id": "main",')
onLog?.(` "workspace": "${OPENCLAW_CONTAINER_HOME}/workspace",`)
onLog?.(' "model": "openrouter/anthropic/claude-sonnet-4.5"')
onLog?.(' }')
onLog?.(']')
onLog?.('done')
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const agents = await client.listAgents()
expect(agents).toEqual([
{
agentId: 'main',
name: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
model: 'openrouter/anthropic/claude-sonnet-4.5',
},
])
})
it('skips structured JSON logs before the real agent list payload', async () => {
const execInContainer = mock(
async (_command: string[], onLog?: (line: string) => void) => {
onLog?.(
JSON.stringify({
level: 'info',
message: 'agent list requested',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
}),
)
onLog?.(
JSON.stringify([
{
id: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
model: 'openrouter/anthropic/claude-sonnet-4.5',
},
]),
)
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const agents = await client.listAgents()
expect(agents).toEqual([
{
agentId: 'main',
name: 'main',
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
model: 'openrouter/anthropic/claude-sonnet-4.5',
},
])
})
it('preserves exit details when the CLI fails', async () => {
const execInContainer = mock(
async (_command: string[], onLog?: (line: string) => void) => {
onLog?.('agent already exists')
return 1
},
)
const client = new OpenClawCliClient({ execInContainer })
await expect(client.listAgents()).rejects.toThrow('agent already exists')
})
it('parses config get output from mixed logs and pretty-printed JSON', async () => {
const execInContainer = mock(
async (command: string[], onLog?: (line: string) => void) => {
if (command[2] === 'config' && command[3] === 'get') {
onLog?.('reading config')
onLog?.('{')
onLog?.(' "gateway": {')
onLog?.(' "mode": "local"')
onLog?.(' }')
onLog?.('}')
onLog?.('done')
}
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const config = await client.getConfig('gateway')
expect(config).toEqual({
gateway: {
mode: 'local',
},
})
})
it('skips structured JSON log lines before config get payloads', async () => {
const execInContainer = mock(
async (command: string[], onLog?: (line: string) => void) => {
if (command[2] === 'config' && command[3] === 'get') {
onLog?.(
JSON.stringify({
level: 'info',
message: 'reading config',
}),
)
onLog?.('{')
onLog?.(' "gateway": {')
onLog?.(' "mode": "local"')
onLog?.(' }')
onLog?.('}')
}
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const config = await client.getConfig('gateway')
expect(config).toEqual({
gateway: {
mode: 'local',
},
})
})
it('skips structured JSON log lines before config validate payloads', async () => {
const execInContainer = mock(
async (command: string[], onLog?: (line: string) => void) => {
if (command[2] === 'config' && command[3] === 'validate') {
onLog?.(
JSON.stringify({
level: 'info',
message: 'validating config',
}),
)
onLog?.(
JSON.stringify({
ok: true,
warnings: [],
}),
)
}
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const result = await client.validateConfig()
expect(result).toEqual({
ok: true,
warnings: [],
})
})
it('keeps the config get payload when a structured JSON log follows it', async () => {
const execInContainer = mock(
async (command: string[], onLog?: (line: string) => void) => {
if (command[2] === 'config' && command[3] === 'get') {
onLog?.('{')
onLog?.(' "gateway": {')
onLog?.(' "mode": "local"')
onLog?.(' }')
onLog?.('}')
onLog?.(
JSON.stringify({
level: 'info',
message: 'config fetched',
}),
)
}
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const config = await client.getConfig('gateway')
expect(config).toEqual({
gateway: {
mode: 'local',
},
})
})
it('keeps the config validate payload when a structured JSON log follows it', async () => {
const execInContainer = mock(
async (command: string[], onLog?: (line: string) => void) => {
if (command[2] === 'config' && command[3] === 'validate') {
onLog?.(
JSON.stringify({
ok: true,
warnings: [],
}),
)
onLog?.(
JSON.stringify({
level: 'info',
message: 'config validated',
}),
)
}
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const result = await client.validateConfig()
expect(result).toEqual({
ok: true,
warnings: [],
})
})
})

View File

@@ -1,42 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it } from 'bun:test'
import { mergeEnvContent } from '../../../../src/api/services/openclaw/openclaw-env'
describe('mergeEnvContent', () => {
it('appends new env keys and normalizes trailing newline', () => {
expect(
mergeEnvContent('OPENAI_API_KEY=sk-old', {
ANTHROPIC_API_KEY: 'ant-key',
}),
).toEqual({
changed: true,
content: 'OPENAI_API_KEY=sk-old\nANTHROPIC_API_KEY=ant-key\n',
})
})
it('overwrites existing keys when values change', () => {
expect(
mergeEnvContent('OPENAI_API_KEY=sk-old\n', {
OPENAI_API_KEY: 'sk-new',
}),
).toEqual({
changed: true,
content: 'OPENAI_API_KEY=sk-new\n',
})
})
it('reports unchanged when incoming values match existing content', () => {
expect(
mergeEnvContent('OPENAI_API_KEY=sk-test\n', {
OPENAI_API_KEY: 'sk-test',
}),
).toEqual({
changed: false,
content: 'OPENAI_API_KEY=sk-test\n',
})
})
})

View File

@@ -1,516 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it, mock } from 'bun:test'
import { OpenClawSessionNotFoundError } from '../../../../src/api/services/openclaw/errors'
import { OpenClawHttpClient } from '../../../../src/api/services/openclaw/openclaw-http-client'
describe('OpenClawHttpClient', () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
it('maps chat completion deltas into BrowserOS stream events', async () => {
const fetchMock = mock((_url: string | URL, _init?: RequestInit) =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
),
)
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
),
)
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
),
)
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const stream = await client.streamChat({
agentId: 'research',
sessionKey: 'session-123',
message: 'hi',
history: [{ role: 'assistant', content: 'Earlier reply' }],
})
const events = await readEvents(stream)
const call = fetchMock.mock.calls[0]
expect(call?.[0]).toBe('http://127.0.0.1:18789/v1/chat/completions')
expect(call?.[1]).toMatchObject({
method: 'POST',
headers: {
Authorization: 'Bearer gateway-token',
'Content-Type': 'application/json',
},
})
expect(JSON.parse(String(call?.[1]?.body))).toEqual({
model: 'openclaw/research',
stream: true,
messages: [
{ role: 'assistant', content: 'Earlier reply' },
{ role: 'user', content: 'hi' },
],
user: 'browseros:research:session-123',
})
expect(events).toEqual([
{ type: 'text-delta', data: { text: 'Hello' } },
{ type: 'text-delta', data: { text: ' world' } },
{ type: 'done', data: { text: 'Hello world' } },
])
})
it('uses openclaw for the main agent', async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
await client.streamChat({
agentId: 'main',
sessionKey: 'session-123',
message: 'hi',
})
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as {
model: string
}
expect(body.model).toBe('openclaw')
})
it('throws on non-success HTTP responses', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response('Unauthorized', { status: 401 })),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
await expect(
client.streamChat({
agentId: 'research',
sessionKey: 'session-123',
message: 'hi',
}),
).rejects.toThrow('Unauthorized')
})
it('checks gateway authentication with the current bearer token', async () => {
const fetchMock = mock(() => Promise.resolve(new Response('{}')))
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
await expect(client.isAuthenticated()).resolves.toBe(true)
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'http://127.0.0.1:18789/v1/models',
)
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'GET',
headers: {
Authorization: 'Bearer gateway-token',
},
})
})
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')
await expect(client.isAuthenticated()).resolves.toBe(false)
})
it('treats failed gateway authentication probes as unavailable', async () => {
globalThis.fetch = mock(() =>
Promise.reject(new Error('connect ECONNREFUSED')),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
await expect(client.isAuthenticated()).resolves.toBe(false)
})
it('surfaces an error when OpenClaw finishes without assistant text', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
),
)
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
),
),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const stream = await client.streamChat({
agentId: 'main',
sessionKey: 'session-123',
message: 'hi',
})
await expect(readEvents(stream)).resolves.toEqual([
{
type: 'error',
data: {
message: "Agent couldn't generate a response. Please try again.",
},
},
])
})
it('stops processing batched SSE events after a malformed chunk closes the stream', async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n' +
'data: not-json\n\n' +
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
),
)
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const stream = await client.streamChat({
agentId: 'research',
sessionKey: 'session-123',
message: 'hi',
})
await expect(readEvents(stream)).resolves.toEqual([
{ type: 'text-delta', data: { text: 'Hello' } },
{
type: 'error',
data: { message: 'Failed to parse OpenClaw chat stream chunk' },
},
])
})
describe('getSessionHistory', () => {
it('sends GET with bearer auth and forwards limit/cursor as query params', async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
sessionKey: 'agent:main:main',
messages: [
{ role: 'user', content: 'hi', messageSeq: 1 },
{ role: 'assistant', content: 'hello', messageSeq: 2 },
],
cursor: null,
hasMore: false,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const result = await client.getSessionHistory('agent:main:main', {
limit: 50,
cursor: 'abc',
})
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(result).toEqual({
sessionKey: 'agent:main:main',
messages: [
{ role: 'user', content: 'hi', messageSeq: 1 },
{ role: 'assistant', content: 'hello', messageSeq: 2 },
],
cursor: null,
hasMore: false,
})
})
it('omits limit and cursor from the query when undefined', 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, async () => 'gateway-token')
await client.getSessionHistory('k')
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'http://127.0.0.1:18789/sessions/k/history',
)
})
it('throws OpenClawSessionNotFoundError on 404', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response('not found', { status: 404 })),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
await expect(
client.getSessionHistory('missing-key'),
).rejects.toBeInstanceOf(OpenClawSessionNotFoundError)
})
it('surfaces the response body on other non-2xx responses', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response('boom', { status: 500 })),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
await expect(client.getSessionHistory('k')).rejects.toThrow('boom')
})
it('propagates the abort signal to fetch', async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ sessionKey: 'k', messages: [] }), {
status: 200,
}),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const controller = new AbortController()
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
await client.getSessionHistory('k', { signal: controller.signal })
expect(fetchMock.mock.calls[0]?.[1]?.signal).toBe(controller.signal)
})
})
describe('streamSessionHistory', () => {
it('parses named history/message SSE events into typed events', async () => {
const fetchMock = mock(() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
controller.enqueue(
encoder.encode(
'event: history\ndata: {"sessionKey":"k","messages":[{"role":"user","content":"hi","messageSeq":1}],"cursor":null,"hasMore":false}\n\n',
),
)
controller.enqueue(
encoder.encode(
'event: message\ndata: {"sessionKey":"k","messageSeq":2,"message":{"role":"assistant","content":"hey","messageSeq":2}}\n\n',
),
)
controller.close()
},
}),
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
),
),
)
globalThis.fetch = fetchMock as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const stream = await client.streamSessionHistory('k', { limit: 20 })
const events = await readEvents(stream)
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'http://127.0.0.1:18789/sessions/k/history?limit=20',
)
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'GET',
headers: {
Accept: 'text/event-stream',
Authorization: 'Bearer gateway-token',
},
})
expect(events).toEqual([
{
type: 'history',
data: {
sessionKey: 'k',
messages: [{ role: 'user', content: 'hi', messageSeq: 1 }],
cursor: null,
hasMore: false,
},
},
{
type: 'message',
data: {
sessionKey: 'k',
messageSeq: 2,
message: { role: 'assistant', content: 'hey', messageSeq: 2 },
},
},
])
})
it('forwards upstream error frames and closes', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
controller.enqueue(
encoder.encode(
'event: error\ndata: {"message":"upstream exploded"}\n\n',
),
)
controller.close()
},
}),
{ status: 200 },
),
),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const stream = await client.streamSessionHistory('k')
await expect(readEvents(stream)).resolves.toEqual([
{ type: 'error', data: { message: 'upstream exploded' } },
])
})
it('throws OpenClawSessionNotFoundError on 404', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response('not found', { status: 404 })),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
await expect(client.streamSessionHistory('k')).rejects.toBeInstanceOf(
OpenClawSessionNotFoundError,
)
})
it('closes when the abort signal fires mid-stream', async () => {
const ac = new AbortController()
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
controller.enqueue(
encoder.encode(
'event: history\ndata: {"sessionKey":"k","messages":[]}\n\n',
),
)
// Keep the stream open; abort should close it from our side.
await new Promise((resolve) => {
ac.signal.addEventListener(
'abort',
() => resolve(undefined),
{
once: true,
},
)
})
controller.close()
},
}),
{ status: 200 },
),
),
) as typeof globalThis.fetch
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
const stream = await client.streamSessionHistory('k', {
signal: ac.signal,
})
const reader = stream.getReader()
const first = await reader.read()
expect(first.done).toBe(false)
expect(first.value).toMatchObject({ type: 'history' })
ac.abort()
const next = await reader.read()
expect(next.done).toBe(true)
})
})
})
async function readEvents(
stream: ReadableStream<{ type: string; data: Record<string, unknown> }>,
): Promise<Array<{ type: string; data: Record<string, unknown> }>> {
const reader = stream.getReader()
const events: Array<{ type: string; data: Record<string, unknown> }> = []
while (true) {
const { done, value } = await reader.read()
if (done) break
events.push(value)
}
return events
}

View File

@@ -0,0 +1,83 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import {
configurePodmanRuntime,
getPodmanRuntime,
resolveBundledPodmanPath,
} from '../../../../src/api/services/openclaw/podman-runtime'
describe('podman runtime', () => {
let tempDir: string
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browseros-podman-test-'))
})
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
configurePodmanRuntime({ podmanPath: 'podman' })
})
it('returns the bundled podman path when the executable exists', () => {
const bundledPath = path.join(
tempDir,
'bin',
'third_party',
'podman',
'podman',
)
fs.mkdirSync(path.dirname(bundledPath), { recursive: true })
fs.writeFileSync(bundledPath, 'podman')
expect(resolveBundledPodmanPath(tempDir, 'darwin')).toBe(bundledPath)
})
it('uses the windows executable name for bundled podman', () => {
const bundledPath = path.join(
tempDir,
'bin',
'third_party',
'podman',
'podman.exe',
)
fs.mkdirSync(path.dirname(bundledPath), { recursive: true })
fs.writeFileSync(bundledPath, 'podman')
expect(resolveBundledPodmanPath(tempDir, 'win32')).toBe(bundledPath)
})
it('returns null when no bundled podman executable exists', () => {
expect(resolveBundledPodmanPath(tempDir, 'darwin')).toBeNull()
})
it('configures the runtime to prefer the bundled podman path', () => {
const bundledPath = path.join(
tempDir,
'bin',
'third_party',
'podman',
'podman',
)
fs.mkdirSync(path.dirname(bundledPath), { recursive: true })
fs.writeFileSync(bundledPath, 'podman')
const runtime = configurePodmanRuntime({ resourcesDir: tempDir })
expect(runtime.getPodmanPath()).toBe(bundledPath)
expect(getPodmanRuntime().getPodmanPath()).toBe(bundledPath)
})
it('falls back to PATH podman when no bundled executable is present', () => {
const runtime = configurePodmanRuntime({ resourcesDir: tempDir })
expect(runtime.getPodmanPath()).toBe('podman')
})
})

View File

@@ -1,108 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { PATHS } from '@browseros/shared/constants/paths'
import {
getAgentCacheDir,
getBrowserosDir,
getCacheDir,
getVmCacheDir,
logDevelopmentBrowserosDir,
} from '../src/lib/browseros-dir'
import { logger } from '../src/lib/logger'
describe('getBrowserosDir', () => {
const originalNodeEnv = process.env.NODE_ENV
beforeEach(() => {
delete process.env.NODE_ENV
})
afterEach(() => {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV
return
}
process.env.NODE_ENV = originalNodeEnv
})
it('uses a separate home directory in development', () => {
process.env.NODE_ENV = 'development'
expect(getBrowserosDir()).toBe(join(homedir(), '.browseros-dev'))
})
it('uses the standard home directory outside development', () => {
process.env.NODE_ENV = 'test'
expect(getBrowserosDir()).toBe(join(homedir(), PATHS.BROWSEROS_DIR_NAME))
})
it('logs the resolved development directory path', () => {
process.env.NODE_ENV = 'development'
const originalInfo = logger.info
const info = mock(() => {})
logger.info = info
try {
logDevelopmentBrowserosDir()
expect(info).toHaveBeenCalledWith(
`Using development BrowserOS directory: ${join(homedir(), '.browseros-dev')}`,
)
} finally {
logger.info = originalInfo
}
})
it('does not log a development directory outside development', () => {
process.env.NODE_ENV = 'test'
const originalInfo = logger.info
const info = mock(() => {})
logger.info = info
try {
logDevelopmentBrowserosDir()
expect(info).not.toHaveBeenCalled()
} finally {
logger.info = originalInfo
}
})
it('uses the development cache directory in development', () => {
process.env.NODE_ENV = 'development'
expect(getCacheDir()).toBe(join(homedir(), '.browseros-dev', 'cache'))
})
it('uses the standard cache directory outside development', () => {
process.env.NODE_ENV = 'test'
expect(getCacheDir()).toBe(
join(homedir(), PATHS.BROWSEROS_DIR_NAME, 'cache'),
)
})
it('uses a vm cache directory below cache', () => {
process.env.NODE_ENV = 'development'
expect(getVmCacheDir()).toBe(
join(homedir(), '.browseros-dev', 'cache', 'vm'),
)
})
it('uses an agent image cache directory below vm cache', () => {
process.env.NODE_ENV = 'development'
expect(getAgentCacheDir()).toBe(
join(homedir(), '.browseros-dev', 'cache', 'vm', 'images'),
)
})
})

View File

@@ -1,97 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { ContainerCli } from '../../src/lib/container'
import { LimaCli, type VmManifest, VmRuntime } from '../../src/lib/vm'
import {
getCachedManifestPath,
getContainerdSocketPath,
VM_NAME,
} from '../../src/lib/vm/paths'
const LIVE_VM_SMOKE_TIMEOUT_MS = 10 * 60 * 1000
const liveIt = process.env.LIVE_VM_SMOKE === '1' ? it : it.skip
const limactlPath = process.env.LIMACTL_PATH ?? 'limactl'
const templatePath = resolve(
import.meta.dir,
'../../../../packages/build-tools/template/browseros-vm.yaml',
)
const manifest: VmManifest = {
schemaVersion: 2,
updatedAt: '2026-04-22T00:00:00.000Z',
agents: {},
}
describe('BrowserOS VM live smoke', () => {
let root: string
let limaHome: string
beforeEach(async () => {
root = await mkdtemp('/tmp/bovm-')
limaHome = join(root, 'lima')
const manifestPath = getCachedManifestPath(root)
await mkdir(dirname(manifestPath), { recursive: true })
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
})
afterEach(async () => {
if (process.env.LIVE_VM_SMOKE === '1') {
await new LimaCli({ limactlPath, limaHome })
.delete(VM_NAME)
.catch(() => undefined)
}
await rm(root, { recursive: true, force: true })
})
liveIt(
'creates, starts, uses, stops, and deletes the BrowserOS Lima VM',
async () => {
expect(existsSync(templatePath)).toBe(true)
const runtime = new VmRuntime({
limactlPath,
limaHome,
templatePath,
browserosRoot: root,
readinessTimeoutMs: 5 * 60 * 1000,
readinessPollMs: 1000,
})
const cli = new ContainerCli({
limactlPath,
limaHome,
vmName: VM_NAME,
})
await runtime.ensureReady()
expect((await stat(getContainerdSocketPath(root))).isSocket()).toBe(true)
const nerdctlInfoOutput: string[] = []
const nerdctlInfo = await cli.runCommand(['info'], (line) =>
nerdctlInfoOutput.push(line),
)
if (nerdctlInfo.exitCode !== 0) {
throw new Error(
`nerdctl info failed with exit ${nerdctlInfo.exitCode}:\n${nerdctlInfoOutput.join('\n')}`,
)
}
await cli.pullImage('docker.io/library/hello-world:latest')
const secondStart = Date.now()
await runtime.ensureReady()
expect(Date.now() - secondStart).toBeLessThan(10_000)
await runtime.stopVm()
const vm = (await new LimaCli({ limactlPath, limaHome }).list()).find(
(entry) => entry.name === VM_NAME,
)
expect(vm?.status).toBe('Stopped')
},
LIVE_VM_SMOKE_TIMEOUT_MS,
)
})

View File

@@ -1,201 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { ContainerCli } from '../../../src/lib/container/container-cli'
import { ContainerCliError } from '../../../src/lib/vm/errors'
import { fakeSsh } from '../../__helpers__/fake-ssh'
describe('ContainerCli', () => {
let tempDir: string
let logPath: string
beforeEach(async () => {
tempDir = await mkdtemp('/tmp/container-cli-')
logPath = join(tempDir, 'ssh.log')
})
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true })
})
it('checks image existence with nerdctl image inspect', async () => {
const sshPath = await fakeSsh({}, logPath)
const cli = await createCli(sshPath, tempDir)
await expect(cli.imageExists('openclaw:v1')).resolves.toBe(true)
const sshConfig = sshConfigPath(tempDir)
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`${sshPrefix(sshConfig)} 'nerdctl' 'image' 'inspect' 'openclaw:v1'`,
)
})
it('returns false when image inspect exits non-zero', async () => {
const sshPath = await fakeSsh({ stderr: 'missing', exit: 1 }, logPath)
const cli = await createCli(sshPath, tempDir)
await expect(cli.imageExists('openclaw:v1')).resolves.toBe(false)
})
it('pulls images with progress and throws typed command errors', async () => {
const sshPath = await fakeSsh(
{ stdout: 'pulling\n', stderr: 'denied', exit: 2 },
logPath,
)
const cli = await createCli(sshPath, tempDir)
const lines: string[] = []
const error = await cli
.pullImage('openclaw:v1', (line) => lines.push(line))
.catch((err) => err)
expect(error).toBeInstanceOf(ContainerCliError)
expect(error.exitCode).toBe(2)
expect(error.stderr).toBe('denied')
expect(lines).toContain('pulling')
expect(lines).toContain('denied')
})
it('loads images from guest tarballs and returns loaded refs', async () => {
const sshPath = await fakeSsh(
{ stdout: 'Loaded image(s): openclaw:v1\n' },
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(
cli.loadImage('/mnt/browseros/cache/images/openclaw.tar.gz'),
).resolves.toEqual(['openclaw:v1'])
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'load' '-i' '/mnt/browseros/cache/images/openclaw.tar.gz'`,
)
})
it('creates containers from typed specs', async () => {
const sshPath = await fakeSsh({}, logPath)
const cli = await createCli(sshPath, tempDir)
await cli.createContainer({
name: 'gateway',
image: 'openclaw:v1',
restart: 'unless-stopped',
ports: [{ hostIp: '127.0.0.1', hostPort: 18789, containerPort: 18789 }],
envFile: '/mnt/browseros/vm/openclaw/.env',
env: { HOME: '/home/node', NODE_ENV: 'production' },
mounts: [
{
source: '/mnt/browseros/vm/openclaw',
target: '/home/node',
readonly: true,
},
],
addHosts: ['host.containers.internal:192.168.5.2'],
health: {
cmd: 'curl -sf http://127.0.0.1:18789/healthz',
interval: '30s',
timeout: '10s',
retries: 3,
},
command: ['node', 'dist/index.js', 'gateway'],
})
await expect(readFile(logPath, 'utf8')).resolves.toContain(
[
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'create'`,
"'--name' 'gateway'",
"'--restart' 'unless-stopped'",
"'-p' '127.0.0.1:18789:18789'",
"'--env-file' '/mnt/browseros/vm/openclaw/.env'",
"'-e' 'HOME=/home/node'",
"'-e' 'NODE_ENV=production'",
"'-v' '/mnt/browseros/vm/openclaw:/home/node:ro'",
"'--add-host' 'host.containers.internal:192.168.5.2'",
"'--health-cmd' 'curl -sf http://127.0.0.1:18789/healthz'",
"'--health-interval' '30s'",
"'--health-timeout' '10s'",
"'--health-retries' '3'",
"'openclaw:v1' 'node' 'dist/index.js' 'gateway'",
].join(' '),
)
})
it('starts, stops, removes, execs, and lists containers', async () => {
const sshPath = await fakeSsh({ stdout: 'gateway\nworker\n' }, logPath)
const cli = await createCli(sshPath, tempDir)
await cli.startContainer('gateway')
await cli.stopContainer('gateway')
await cli.removeContainer('gateway', { force: true })
await expect(cli.exec('gateway', ['node', '--version'])).resolves.toBe(0)
await expect(cli.ps({ namesOnly: true })).resolves.toEqual([
'gateway',
'worker',
])
const log = await readFile(logPath, 'utf8')
expect(log).toContain("lima-browseros-vm 'nerdctl' 'start' 'gateway'")
expect(log).toContain("lima-browseros-vm 'nerdctl' 'stop' 'gateway'")
expect(log).toContain("lima-browseros-vm 'nerdctl' 'rm' '-f' 'gateway'")
expect(log).toContain(
"lima-browseros-vm 'nerdctl' 'exec' 'gateway' 'node' '--version'",
)
expect(log).toContain(
"lima-browseros-vm 'nerdctl' 'ps' '--format' '{{.Names}}'",
)
})
it('tolerates removal when the container is already absent', async () => {
const sshPath = await fakeSsh(
{ stderr: 'no such container', exit: 1 },
logPath,
)
const cli = await createCli(sshPath, tempDir)
await expect(cli.removeContainer('gateway', { force: true })).resolves.toBe(
undefined,
)
})
it('tails logs and returns a stop handle', async () => {
const sshPath = await fakeSsh({ stdout: 'line\n' }, logPath)
const cli = await createCli(sshPath, tempDir)
const lines: string[] = []
const stop = cli.tailLogs('gateway', (line) => lines.push(line))
await Bun.sleep(20)
stop()
expect(lines).toEqual(['line'])
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'logs' '-f' '-n' '0' 'gateway'`,
)
})
})
async function createCli(
sshPath: string,
tempDir: string,
): Promise<ContainerCli> {
const configPath = sshConfigPath(tempDir)
await mkdir(join(tempDir, 'lima', 'browseros-vm'), { recursive: true })
await writeFile(configPath, '')
return new ContainerCli({
limactlPath: 'unused',
limaHome: join(tempDir, 'lima'),
sshPath,
vmName: 'browseros-vm',
})
}
function sshConfigPath(tempDir: string): string {
return join(tempDir, 'lima', 'browseros-vm', 'ssh.config')
}
function sshPrefix(configPath: string): string {
return `ARGS:-F ${configPath} lima-browseros-vm`
}

View File

@@ -1,138 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
import type { ContainerCli } from '../../../src/lib/container/container-cli'
import { ImageLoader } from '../../../src/lib/container/image-loader'
import { ContainerCliError, ImageLoadError } from '../../../src/lib/vm/errors'
import type { VmManifest } from '../../../src/lib/vm/manifest'
import * as paths from '../../../src/lib/vm/paths'
const manifest: VmManifest = {
schemaVersion: 2,
updatedAt: '2026-04-22T00:00:00.000Z',
agents: {
openclaw: {
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
tarballs: {
arm64: {
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
sha256: 'agent-arm',
sizeBytes: 1,
},
x64: {
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
sha256: 'agent-x64',
sizeBytes: 1,
},
},
},
},
}
describe('ImageLoader', () => {
afterEach(() => {
mock.restore()
})
it('returns without loading when the image already exists', async () => {
const cli = new FakeContainerCli([true])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
expect(cli.loadCalls).toEqual([])
})
it('loads a missing image from the guest cache and verifies it exists', async () => {
const cli = new FakeContainerCli([false, true])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
expect(cli.loadCalls).toEqual([
'/mnt/browseros/cache/images/openclaw-2026.4.12-arm64.tar.gz',
])
expect(cli.existsCalls).toEqual([
'ghcr.io/openclaw/openclaw:2026.4.12',
'ghcr.io/openclaw/openclaw:2026.4.12',
])
})
it('resolves image tarballs against the configured BrowserOS root', async () => {
const cli = new FakeContainerCli([false, true])
const browserosRoot = '/tmp/browseros-custom-root'
const loader = new ImageLoader(
cli as never,
manifest,
'arm64',
browserosRoot,
)
const getImageCacheDir = spyOn(paths, 'getImageCacheDir')
const hostPathToGuest = spyOn(paths, 'hostPathToGuest')
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
expect(getImageCacheDir).toHaveBeenCalledWith(browserosRoot)
expect(hostPathToGuest).toHaveBeenCalledWith(
'/tmp/browseros-custom-root/cache/vm/images/openclaw-2026.4.12-arm64.tar.gz',
browserosRoot,
)
})
it('throws ImageLoadError when a loaded image is still absent', async () => {
const cli = new FakeContainerCli([false, false])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
await expect(
loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12'),
).rejects.toThrow(ImageLoadError)
})
it('throws ImageLoadError for unknown refs without loading', async () => {
const cli = new FakeContainerCli([false])
const loader = new ImageLoader(cli as never, manifest, 'arm64')
await expect(loader.ensureImageLoaded('missing:v1')).rejects.toThrow(
ImageLoadError,
)
expect(cli.loadCalls).toEqual([])
})
it('wraps ContainerCliError load failures as ImageLoadError', async () => {
const cli = new FakeContainerCli([false])
cli.loadError = new ContainerCliError('nerdctl load', 125, 'bad archive')
const loader = new ImageLoader(cli as never, manifest, 'arm64')
const error = await loader
.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
.catch((err) => err)
expect(error).toBeInstanceOf(ImageLoadError)
expect(error.cause).toBe(cli.loadError)
})
})
class FakeContainerCli
implements Pick<ContainerCli, 'imageExists' | 'loadImage'>
{
existsCalls: string[] = []
loadCalls: string[] = []
loadError: Error | null = null
constructor(private readonly existsResponses: boolean[]) {}
async imageExists(ref: string): Promise<boolean> {
this.existsCalls.push(ref)
return this.existsResponses.shift() ?? false
}
async loadImage(path: string): Promise<string[]> {
this.loadCalls.push(path)
if (this.loadError) throw this.loadError
return ['loaded']
}
}

View File

@@ -1,60 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it } from 'bun:test'
import {
ContainerCliError,
ImageLoadError,
LimaCommandError,
ManifestMissingError,
VmError,
VmNotReadyError,
VmStateCorruptedError,
} from '../../../src/lib/vm/errors'
import { VM_TELEMETRY_EVENTS } from '../../../src/lib/vm/telemetry'
describe('VM errors', () => {
it('keeps all VM domain errors under VmError', () => {
const errors = [
new VmError('base'),
new VmNotReadyError('not ready'),
new VmStateCorruptedError('corrupt'),
new LimaCommandError('limactl start', 7, 'bad lima'),
new ContainerCliError('nerdctl pull', 8, 'bad nerdctl'),
new ImageLoadError('openclaw:v1', 'bad image'),
new ManifestMissingError('/tmp/manifest.json'),
]
for (const error of errors) {
expect(error).toBeInstanceOf(Error)
expect(error).toBeInstanceOf(VmError)
}
})
it('carries command failure details', () => {
const lima = new LimaCommandError('limactl start', 12, 'stderr text')
const container = new ContainerCliError(
'nerdctl pull',
13,
'nerdctl stderr',
)
expect(lima.exitCode).toBe(12)
expect(lima.stderr).toBe('stderr text')
expect(container.exitCode).toBe(13)
expect(container.stderr).toBe('nerdctl stderr')
})
it('exports VM telemetry event names', () => {
expect(VM_TELEMETRY_EVENTS.ensureReadyStart).toBe('vm.ensure_ready.start')
expect(VM_TELEMETRY_EVENTS.downgradeDetected).toBe('vm.downgrade.detected')
expect(VM_TELEMETRY_EVENTS.nerdctlWaitTimeout).toBe(
'vm.nerdctl_wait.timeout',
)
expect(VM_TELEMETRY_EVENTS.migrationOpenClawMoved).toBe(
'vm.migration.openclaw_moved',
)
})
})

View File

@@ -1,211 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import {
afterEach,
beforeEach,
describe,
expect,
it,
mock,
spyOn,
} from 'bun:test'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { logger } from '../../../src/lib/logger'
import { LimaCommandError, VmNotReadyError } from '../../../src/lib/vm/errors'
import { LimaCli } from '../../../src/lib/vm/lima-cli'
import { VM_TELEMETRY_EVENTS } from '../../../src/lib/vm/telemetry'
import { fakeLimactl } from '../../__helpers__/fake-limactl'
import { fakeSsh } from '../../__helpers__/fake-ssh'
describe('LimaCli', () => {
let tempDir: string
let logPath: string
let limaHome: string
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'lima-cli-test-'))
logPath = join(tempDir, 'calls.log')
limaHome = join(tempDir, 'lima-home')
})
afterEach(async () => {
mock.restore()
await rm(tempDir, { recursive: true, force: true })
})
it('parses limactl list JSON output', async () => {
const limactlPath = await fakeLimactl(
{
list: {
stdout: JSON.stringify([
{
name: 'browseros-vm',
status: 'Running',
dir: '/lima/browseros-vm',
},
]),
},
},
logPath,
)
const cli = new LimaCli({ limactlPath, limaHome })
await expect(cli.list()).resolves.toEqual([
{ name: 'browseros-vm', status: 'Running', dir: '/lima/browseros-vm' },
])
})
it('returns an empty VM list when limactl prints no output', async () => {
const limactlPath = await fakeLimactl({ list: { stdout: '' } }, logPath)
const cli = new LimaCli({ limactlPath, limaHome })
await expect(cli.list()).resolves.toEqual([])
})
it('creates VMs with LIMA_HOME and the expected argv', async () => {
const limactlPath = await fakeLimactl({ create: {} }, logPath)
const cli = new LimaCli({ limactlPath, limaHome })
await cli.create('browseros-vm', '/tmp/browseros-vm.yaml')
await expect(readFile(logPath, 'utf8')).resolves.toContain(
'ARGS:create --tty=false --name=browseros-vm /tmp/browseros-vm.yaml',
)
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`LIMA_HOME:${limaHome}`,
)
})
it('starts VMs with tty disabled', async () => {
const limactlPath = await fakeLimactl({ start: {} }, logPath)
const cli = new LimaCli({ limactlPath, limaHome })
await cli.start('browseros-vm')
await expect(readFile(logPath, 'utf8')).resolves.toContain(
'ARGS:start --tty=false browseros-vm',
)
})
it('throws LimaCommandError with stderr on non-zero exit', async () => {
const limactlPath = await fakeLimactl(
{ start: { stderr: 'cannot start', exit: 2 } },
logPath,
)
const cli = new LimaCli({ limactlPath, limaHome })
const error = await cli.start('browseros-vm').catch((err) => err)
expect(error).toBeInstanceOf(LimaCommandError)
expect(error.exitCode).toBe(2)
expect(error.stderr).toBe('cannot start')
})
it('does not log limactl stderr chunks by default', async () => {
const debug = spyOn(logger, 'debug').mockImplementation(() => {})
const limactlPath = await fakeLimactl(
{ start: { stderr: 'boot noise\n' } },
logPath,
)
const cli = new LimaCli({ limactlPath, limaHome })
await cli.start('browseros-vm')
expect(
debug.mock.calls.some(
([message]) => message === VM_TELEMETRY_EVENTS.limaStderrChunk,
),
).toBe(false)
})
it('stops and deletes VMs', async () => {
const limactlPath = await fakeLimactl({ stop: {}, delete: {} }, logPath)
const cli = new LimaCli({ limactlPath, limaHome })
await cli.stop('browseros-vm')
await cli.delete('browseros-vm')
const log = await readFile(logPath, 'utf8')
expect(log).toContain('ARGS:stop browseros-vm')
expect(log).toContain('ARGS:delete --force browseros-vm')
})
it('runs shell commands and streams stdout and stderr', async () => {
const sshPath = await fakeSsh({ stdout: 'out\n', stderr: 'err\n' }, logPath)
const sshConfig = join(limaHome, 'browseros-vm', 'ssh.config')
await mkdir(join(limaHome, 'browseros-vm'), { recursive: true })
await writeFile(sshConfig, '')
const cli = new LimaCli({ limactlPath: 'unused', limaHome, sshPath })
const lines: string[] = []
await expect(
cli.shell('browseros-vm', ['nerdctl', 'ps'], {
onStdout: (line) => lines.push(`stdout:${line}`),
onStderr: (line) => lines.push(`stderr:${line}`),
}),
).resolves.toBe(0)
expect(lines).toContain('stdout:out')
expect(lines).toContain('stderr:err')
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`ARGS:-F ${sshConfig} lima-browseros-vm 'nerdctl' 'ps'`,
)
})
it('shell-quotes remote commands to preserve argument boundaries', async () => {
const sshPath = await fakeSsh({}, logPath)
const sshConfig = join(limaHome, 'browseros-vm', 'ssh.config')
await mkdir(join(limaHome, 'browseros-vm'), { recursive: true })
await writeFile(sshConfig, '')
const cli = new LimaCli({ limactlPath: 'unused', limaHome, sshPath })
await expect(
cli.shell('browseros-vm', ['sh', '-lc', "echo 'boundary ok'"]),
).resolves.toBe(0)
await expect(readFile(logPath, 'utf8')).resolves.toContain(
`ARGS:-F ${sshConfig} lima-browseros-vm 'sh' '-lc' 'echo '\\''boundary ok'\\'''`,
)
})
it('ignores shell stderr when no stderr stream handler is provided', async () => {
const sshConfig = join(limaHome, 'browseros-vm', 'ssh.config')
await mkdir(join(limaHome, 'browseros-vm'), { recursive: true })
await writeFile(sshConfig, '')
const spawn = spyOn(Bun, 'spawn')
spawn.mockImplementation(
() =>
({
stdout: null,
stderr: null,
exited: Promise.resolve(0),
}) as never,
)
const cli = new LimaCli({ limactlPath: 'limactl', limaHome })
await expect(
cli.shell('browseros-vm', ['true'], {
onStdout: () => {},
}),
).resolves.toBe(0)
expect(spawn).toHaveBeenCalledWith(
['ssh', '-F', sshConfig, 'lima-browseros-vm', "'true'"],
expect.objectContaining({
stdout: 'pipe',
stderr: 'ignore',
}),
)
})
it('throws VmNotReadyError when ssh.config is missing', async () => {
const cli = new LimaCli({ limactlPath: 'limactl', limaHome })
const error = await cli.shell('browseros-vm', ['true']).catch((err) => err)
expect(error).toBeInstanceOf(VmNotReadyError)
})
})

View File

@@ -1,34 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it } from 'bun:test'
import { renderLimaTemplate } from '../../../src/lib/vm/lima-config'
describe('renderLimaTemplate', () => {
it('injects BrowserOS host mounts into the bundled Lima template', () => {
const yaml = renderLimaTemplate(
'minimumLimaVersion: 2.0.0\nmounts: []\nprobes: []\n',
{
vmStateDir: '/Users/me/.browseros/vm',
imageCacheDir: '/Users/me/.browseros/cache/vm/images',
},
)
expect(yaml).toContain('mountPoint: "/mnt/browseros/vm"')
expect(yaml).toContain('location: "/Users/me/.browseros/vm"')
expect(yaml).toContain('mountPoint: "/mnt/browseros/cache/images"')
expect(yaml).toContain('location: "/Users/me/.browseros/cache/vm/images"')
expect(yaml).toContain('probes: []')
})
it('fails loudly if the template no longer has the expected mount marker', () => {
expect(() =>
renderLimaTemplate('minimumLimaVersion: 2.0.0\n', {
vmStateDir: '/state',
imageCacheDir: '/images',
}),
).toThrow('mounts: [] marker')
})
})

View File

@@ -1,137 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { ManifestMissingError } from '../../../src/lib/vm/errors'
import {
agentForArch,
compareVersions,
readCachedManifest,
readInstalledManifest,
type VmManifest,
writeInstalledManifest,
} from '../../../src/lib/vm/manifest'
const manifest: VmManifest = {
schemaVersion: 2,
updatedAt: '2026-04-22T00:00:00.000Z',
agents: {
openclaw: {
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
tarballs: {
arm64: {
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
sha256: 'c',
sizeBytes: 3,
},
x64: {
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
sha256: 'd',
sizeBytes: 4,
},
},
},
},
}
describe('VM manifest helpers', () => {
let root: string
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'browseros-vm-manifest-'))
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
it('reads the cached manifest', async () => {
const manifestPath = join(root, 'cache', 'vm', 'manifest.json')
await mkdir(dirname(manifestPath), { recursive: true })
await Bun.write(manifestPath, `${JSON.stringify(manifest)}\n`)
await expect(readCachedManifest(root)).resolves.toEqual(manifest)
})
it('throws ManifestMissingError when cached manifest is absent', async () => {
await expect(readCachedManifest(root)).rejects.toThrow(ManifestMissingError)
})
it('returns null for a missing installed manifest', async () => {
await expect(readInstalledManifest(root)).resolves.toBeNull()
})
it('reads the installed manifest', async () => {
const manifestPath = join(root, 'vm', 'manifest.json')
await mkdir(dirname(manifestPath), { recursive: true })
await Bun.write(manifestPath, `${JSON.stringify(manifest)}\n`)
await expect(readInstalledManifest(root)).resolves.toEqual(manifest)
})
it('throws on malformed installed manifest JSON', async () => {
const manifestPath = join(root, 'vm', 'manifest.json')
await mkdir(dirname(manifestPath), { recursive: true })
await Bun.write(manifestPath, '{not-json')
await expect(readInstalledManifest(root)).rejects.toThrow()
})
it('writes the installed manifest atomically', async () => {
await writeInstalledManifest(manifest, root)
const raw = await readFile(join(root, 'vm', 'manifest.json'), 'utf8')
expect(JSON.parse(raw)).toEqual(manifest)
})
it('compares installed and cached versions', () => {
const older = { ...manifest, updatedAt: '2026-04-21T00:00:00.000Z' }
const newer = { ...manifest, updatedAt: '2026-04-23T00:00:00.000Z' }
expect(compareVersions(null, manifest)).toBe('fresh')
expect(compareVersions(manifest, manifest)).toBe('same')
expect(compareVersions(older, manifest)).toBe('upgrade')
expect(compareVersions(newer, manifest)).toBe('downgrade')
})
it('compares ISO timestamp versions with time-of-day precision', () => {
const morning = {
...manifest,
updatedAt: '2026-04-22T10:00:00.000Z',
}
const afternoon = {
...manifest,
updatedAt: '2026-04-22T15:00:00.000Z',
}
expect(compareVersions(morning, afternoon)).toBe('upgrade')
expect(compareVersions(afternoon, morning)).toBe('downgrade')
})
it('returns the requested agent tarball for an arch', () => {
expect(agentForArch(manifest, 'openclaw', 'arm64')).toEqual({
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
tarball: {
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
sha256: 'c',
sizeBytes: 3,
},
})
})
it('throws when an agent or arch is absent', () => {
expect(() => agentForArch(manifest, 'missing', 'arm64')).toThrow(
'missing agent',
)
expect(() =>
agentForArch(manifest, 'openclaw', 'x64' as never),
).not.toThrow()
})
})

View File

@@ -1,230 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { PATHS } from '@browseros/shared/constants/paths'
import {
getLegacyOpenClawDir,
getOpenClawDir,
} from '../../../src/lib/browseros-dir'
import {
detectArch,
getCachedManifestPath,
getContainerdSocketPath,
getImageCacheDir,
getInstalledManifestPath,
getLimaHomeDir,
getVmCacheDir,
getVmStateDir,
hostPathToGuest,
resolveBundledLimactl,
resolveBundledLimaTemplate,
} from '../../../src/lib/vm/paths'
describe('VM paths', () => {
const originalNodeEnv = process.env.NODE_ENV
const originalPath = process.env.PATH
afterEach(() => {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = originalNodeEnv
}
if (originalPath === undefined) {
delete process.env.PATH
} else {
process.env.PATH = originalPath
}
})
it('uses production VM directories below .browseros', () => {
process.env.NODE_ENV = 'production'
expect(getLimaHomeDir()).toBe(join(homedir(), '.browseros', 'lima'))
expect(getVmStateDir()).toBe(join(homedir(), '.browseros', 'vm'))
expect(getOpenClawDir()).toBe(
join(homedir(), '.browseros', 'vm', 'openclaw'),
)
})
it('uses development VM directories below .browseros-dev', () => {
process.env.NODE_ENV = 'development'
expect(getLimaHomeDir()).toBe(join(homedir(), '.browseros-dev', 'lima'))
expect(getVmStateDir()).toBe(join(homedir(), '.browseros-dev', 'vm'))
expect(getOpenClawDir()).toBe(
join(homedir(), '.browseros-dev', 'vm', 'openclaw'),
)
})
it('keeps the legacy OpenClaw directory addressable for migration', () => {
process.env.NODE_ENV = 'production'
expect(getLegacyOpenClawDir()).toBe(
join(homedir(), PATHS.BROWSEROS_DIR_NAME, PATHS.OPENCLAW_DIR_NAME),
)
})
it('builds cached and installed manifest paths', () => {
const root = '/Users/foo/.browseros'
expect(getVmCacheDir(root)).toBe('/Users/foo/.browseros/cache/vm')
expect(getImageCacheDir(root)).toBe('/Users/foo/.browseros/cache/vm/images')
expect(getCachedManifestPath(root)).toBe(
'/Users/foo/.browseros/cache/vm/manifest.json',
)
expect(getInstalledManifestPath(root)).toBe(
'/Users/foo/.browseros/vm/manifest.json',
)
expect(getContainerdSocketPath(root)).toBe(
'/Users/foo/.browseros/lima/browseros-vm/sock/containerd.sock',
)
})
it('translates mounted host paths into guest paths', () => {
const root = '/Users/foo/.browseros'
expect(hostPathToGuest('/Users/foo/.browseros/vm/openclaw/x', root)).toBe(
'/mnt/browseros/vm/openclaw/x',
)
expect(
hostPathToGuest('/Users/foo/.browseros/cache/vm/images/a.tar.gz', root),
).toBe('/mnt/browseros/cache/images/a.tar.gz')
})
it('rejects unmapped host paths', () => {
expect(() =>
hostPathToGuest('/tmp/other', '/Users/foo/.browseros'),
).toThrow('not under any known guest mount')
})
it('detects supported host architectures', () => {
expect(detectArch('arm64')).toBe('arm64')
expect(detectArch('x64')).toBe('x64')
})
it('rejects unsupported host architectures', () => {
expect(() => detectArch('ppc64' as NodeJS.Architecture)).toThrow(
'unsupported host arch',
)
})
it('resolves the bundled limactl executable', async () => {
process.env.NODE_ENV = 'production'
const resourcesDir = await mkdtemp(join(tmpdir(), 'limactl-resources-'))
const limactlPath = join(
resourcesDir,
'bin',
'third_party',
'lima',
'limactl',
)
await mkdir(dirname(limactlPath), { recursive: true })
await writeFile(limactlPath, '#!/bin/sh\n')
try {
expect(resolveBundledLimactl(resourcesDir)).toBe(limactlPath)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('uses PATH limactl in development mode', async () => {
process.env.NODE_ENV = 'development'
const binDir = await createFakeLimactlPath()
try {
expect(resolveBundledLimactl('/tmp/missing-dev-resources')).toBe(
join(binDir, 'limactl'),
)
} finally {
await rm(binDir, { recursive: true, force: true })
}
})
it('uses PATH limactl in test mode', async () => {
process.env.NODE_ENV = 'test'
const binDir = await createFakeLimactlPath()
try {
expect(resolveBundledLimactl('/tmp/missing-test-resources')).toBe(
join(binDir, 'limactl'),
)
} finally {
await rm(binDir, { recursive: true, force: true })
}
})
it('throws with a brew install hint when host limactl is missing', async () => {
process.env.NODE_ENV = 'development'
const binDir = await mkdtemp(join(tmpdir(), 'missing-host-limactl-'))
process.env.PATH = binDir
try {
expect(() => resolveBundledLimactl('/tmp/missing-dev-resources')).toThrow(
'brew install lima',
)
} finally {
await rm(binDir, { recursive: true, force: true })
}
})
it('throws with a build-tools hint when bundled limactl is missing', () => {
process.env.NODE_ENV = 'production'
expect(() => resolveBundledLimactl('/tmp/missing-resources')).toThrow(
'build-tools README',
)
})
it('resolves the bundled Lima template', async () => {
process.env.NODE_ENV = 'production'
const resourcesDir = await mkdtemp(join(tmpdir(), 'lima-template-'))
const templatePath = join(resourcesDir, 'vm', 'browseros-vm.yaml')
await mkdir(dirname(templatePath), { recursive: true })
await writeFile(templatePath, 'mounts: []\n')
try {
expect(resolveBundledLimaTemplate(resourcesDir)).toBe(templatePath)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('resolves the source Lima template from a package workspace in test mode', async () => {
process.env.NODE_ENV = 'test'
const workspaceDir = await mkdtemp(join(tmpdir(), 'lima-source-template-'))
const resourcesDir = join(workspaceDir, 'packages', 'browseros-agent')
const templatePath = join(
workspaceDir,
'packages',
'build-tools',
'template',
'browseros-vm.yaml',
)
await mkdir(resourcesDir, { recursive: true })
await mkdir(dirname(templatePath), { recursive: true })
await writeFile(templatePath, 'mounts: []\n')
try {
expect(resolveBundledLimaTemplate(resourcesDir)).toBe(templatePath)
} finally {
await rm(workspaceDir, { recursive: true, force: true })
}
})
})
async function createFakeLimactlPath(): Promise<string> {
const binDir = await mkdtemp(join(tmpdir(), 'host-limactl-'))
const limactlPath = join(binDir, 'limactl')
await writeFile(limactlPath, '#!/bin/sh\n')
await chmod(limactlPath, 0o755)
process.env.PATH = binDir
return binDir
}

View File

@@ -1,490 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { logger } from '../../../src/lib/logger'
import { VmNotReadyError } from '../../../src/lib/vm/errors'
import type { VmManifest } from '../../../src/lib/vm/manifest'
import {
getCachedManifestPath,
getInstalledManifestPath,
VM_NAME,
} from '../../../src/lib/vm/paths'
import { VM_TELEMETRY_EVENTS } from '../../../src/lib/vm/telemetry'
import { VmRuntime } from '../../../src/lib/vm/vm-runtime'
import { fakeLimactl } from '../../__helpers__/fake-limactl'
import { fakeSsh } from '../../__helpers__/fake-ssh'
const manifest: VmManifest = {
schemaVersion: 2,
updatedAt: '2026-04-22T00:00:00.000Z',
agents: {
openclaw: {
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
tarballs: {
arm64: {
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
sha256: 'agent-arm',
sizeBytes: 1,
},
x64: {
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
sha256: 'agent-x64',
sizeBytes: 1,
},
},
},
},
}
describe('VmRuntime', () => {
let root: string
let limaHome: string
let logPath: string
let templatePath: string
beforeEach(async () => {
root = await mkdtemp('/tmp/vmrt-')
limaHome = join(root, 'lima')
logPath = join(root, 'limactl.log')
templatePath = join(root, 'browseros-vm.yaml')
await writeCachedManifest(root)
await writeFile(templatePath, 'minimumLimaVersion: 2.0.0\nmounts: []\n')
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
it('provisions a fresh VM, waits for rootless nerdctl, and installs the manifest', async () => {
const limactlPath = await fakeLimactl(
{ list: { stdout: '' }, create: {}, start: {} },
logPath,
)
const sshPath = await prepareReadySsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
})
await runtime.ensureReady()
const log = await readFile(logPath, 'utf8')
expect(log).toContain(`ARGS:create --tty=false --name=${VM_NAME}`)
expect(log).toContain(`ARGS:start --tty=false ${VM_NAME}`)
expect(log).toContain(`lima-${VM_NAME} 'nerdctl' 'info'`)
await expect(
readFile(getInstalledManifestPath(root), 'utf8'),
).resolves.toContain(manifest.updatedAt)
await expect(
readFile(join(limaHome, `${VM_NAME}.yaml`), 'utf8'),
).resolves.toContain('mountPoint: "/mnt/browseros/vm"')
})
it('returns fast when the VM is already running and manifests match', async () => {
await writeInstalledManifest(root)
const limactlPath = await fakeLimactl(
{
list: {
stdout: JSON.stringify([
{ name: VM_NAME, status: 'Running', dir: limaHome },
]),
},
create: { stderr: 'should not create', exit: 9 },
start: { stderr: 'should not start', exit: 9 },
},
logPath,
)
const sshPath = await prepareReadySsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
browserosRoot: root,
})
await runtime.ensureReady()
const log = await readFile(logPath, 'utf8')
expect(log).toContain('ARGS:list --format json')
expect(log).not.toContain('ARGS:create')
expect(log).not.toContain('ARGS:start')
})
it('starts an existing stopped VM without recreating it', async () => {
await writeInstalledManifest(root)
const limactlPath = await fakeLimactl(
{
list: {
stdout: JSON.stringify([
{ name: VM_NAME, status: 'Stopped', dir: limaHome },
]),
},
start: {},
},
logPath,
)
const sshPath = await prepareReadySsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
browserosRoot: root,
})
await runtime.ensureReady()
const log = await readFile(logPath, 'utf8')
expect(log).toContain(`ARGS:start --tty=false ${VM_NAME}`)
expect(log).not.toContain('ARGS:create')
})
it('recreates an existing VM that does not have the containerd runtime marker', async () => {
await writeInstalledManifest(root)
const limactlPath = await fakeLimactl(
{
list: {
stdout: JSON.stringify([
{ name: VM_NAME, status: 'Running', dir: limaHome },
]),
},
stop: {},
delete: {},
create: {},
start: {},
},
logPath,
)
const sshPath = await fakeRootfulThenReadySsh(root, logPath)
await writeSshConfig(limaHome)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
})
await runtime.ensureReady()
const log = await readFile(logPath, 'utf8')
expect(log).toContain(`lima-${VM_NAME} 'nerdctl' 'info'`)
expect(log).toContain(
`lima-${VM_NAME} 'sh' '-lc' 'cat /etc/browseros-vm-version 2>/dev/null || true'`,
)
expect(log).toContain(`ARGS:stop ${VM_NAME}`)
expect(log).toContain(`ARGS:delete --force ${VM_NAME}`)
expect(log).toContain(`ARGS:create --tty=false --name=${VM_NAME}`)
expect(log).toContain(`ARGS:start --tty=false ${VM_NAME}`)
})
it('treats stopVm as idempotent when the VM is already stopped', async () => {
const limactlPath = await fakeLimactl(
{ stop: { stderr: 'instance is not running', exit: 1 } },
logPath,
)
const runtime = new VmRuntime({
limactlPath,
limaHome,
browserosRoot: root,
})
await expect(runtime.stopVm()).resolves.toBeUndefined()
})
it('requires a bundled Lima template for fresh VM provisioning', async () => {
const limactlPath = await fakeLimactl({ list: { stdout: '' } }, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
browserosRoot: root,
})
await expect(runtime.ensureReady()).rejects.toThrow('Lima template path')
})
it('throws VmNotReadyError when rootless nerdctl never becomes ready', async () => {
const limactlPath = await fakeLimactl(
{ list: { stdout: '' }, create: {}, start: {} },
logPath,
)
const sshPath = await prepareFailingSsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
readinessTimeoutMs: 10,
readinessPollMs: 1,
})
await expect(runtime.ensureReady()).rejects.toThrow(VmNotReadyError)
})
it('exposes a reset stub with a follow-up-plan message', async () => {
const limactlPath = await fakeLimactl({}, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
browserosRoot: root,
})
await expect(runtime.reset('bad disk')).rejects.toThrow(
'VmRuntime.reset is not implemented yet',
)
})
it('logs upgrade mismatch and preserves the installed manifest until upgrade happens', async () => {
await writeInstalledManifest(root, '2026-04-21T00:00:00.000Z')
const limactlPath = await fakeLimactl(
{
list: {
stdout: JSON.stringify([
{ name: VM_NAME, status: 'Running', dir: limaHome },
]),
},
},
logPath,
)
const sshPath = await prepareReadySsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
})
const originalWarn = logger.warn
const warnings: Array<{
message: string
meta?: Record<string, unknown>
}> = []
logger.warn = (message, meta) => warnings.push({ message, meta })
try {
await runtime.ensureReady()
} finally {
logger.warn = originalWarn
}
expect(warnings).toContainEqual({
message: VM_TELEMETRY_EVENTS.upgradeDetected,
meta: {
from: '2026-04-21T00:00:00.000Z',
to: '2026-04-22T00:00:00.000Z',
},
})
expect(await readInstalledUpdatedAt(root)).toBe('2026-04-21T00:00:00.000Z')
})
it('logs downgrade mismatch and preserves a newer installed manifest', async () => {
await writeInstalledManifest(root, '2026-04-23T00:00:00.000Z')
const limactlPath = await fakeLimactl(
{
list: {
stdout: JSON.stringify([
{ name: VM_NAME, status: 'Running', dir: limaHome },
]),
},
},
logPath,
)
const sshPath = await prepareReadySsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
})
const originalWarn = logger.warn
const warnings: Array<{
message: string
meta?: Record<string, unknown>
}> = []
logger.warn = (message, meta) => warnings.push({ message, meta })
try {
await runtime.ensureReady()
} finally {
logger.warn = originalWarn
}
expect(warnings).toContainEqual({
message: VM_TELEMETRY_EVENTS.downgradeDetected,
meta: {
from: '2026-04-23T00:00:00.000Z',
to: '2026-04-22T00:00:00.000Z',
},
})
expect(await readInstalledUpdatedAt(root)).toBe('2026-04-23T00:00:00.000Z')
})
it('does not auto-reset when rootless nerdctl readiness fails', async () => {
const limactlPath = await fakeLimactl(
{ list: { stdout: '' }, create: {}, start: {} },
logPath,
)
const sshPath = await prepareFailingSsh(limaHome, logPath)
const runtime = new VmRuntime({
limactlPath,
limaHome,
sshPath,
templatePath,
browserosRoot: root,
readinessTimeoutMs: 10,
readinessPollMs: 1,
})
let resetCalled = false
runtime.reset = async () => {
resetCalled = true
throw new Error('reset called')
}
await expect(runtime.ensureReady()).rejects.toThrow(VmNotReadyError)
expect(resetCalled).toBe(false)
})
it('delegates runCommand through ssh', async () => {
const sshPath = await fakeSsh({}, logPath)
const sshConfig = join(limaHome, VM_NAME, 'ssh.config')
await mkdir(join(limaHome, VM_NAME), { recursive: true })
await writeFile(sshConfig, '')
const runtime = new VmRuntime({
limactlPath: 'unused',
limaHome,
sshPath,
browserosRoot: root,
})
await expect(runtime.runCommand(['nerdctl', 'version'])).resolves.toBe(0)
const log = await readFile(logPath, 'utf8')
expect(log).toContain(
`ARGS:-F ${sshConfig} lima-${VM_NAME} 'nerdctl' 'version'`,
)
})
it('resolves and caches the VM default gateway through ssh', async () => {
const sshPath = await fakeSsh(
{
stdout:
'default via 192.168.5.2 dev eth0 proto dhcp src 192.168.5.15 metric 100\n',
},
logPath,
)
const sshConfig = join(limaHome, VM_NAME, 'ssh.config')
await mkdir(join(limaHome, VM_NAME), { recursive: true })
await writeFile(sshConfig, '')
const runtime = new VmRuntime({
limactlPath: 'unused',
limaHome,
sshPath,
browserosRoot: root,
})
await expect(runtime.getDefaultGateway()).resolves.toBe('192.168.5.2')
await expect(runtime.getDefaultGateway()).resolves.toBe('192.168.5.2')
const log = await readFile(logPath, 'utf8')
expect(log.match(/'ip' '-4' 'route' 'show' 'default'/g)).toHaveLength(1)
})
})
async function writeCachedManifest(root: string): Promise<void> {
const manifestPath = getCachedManifestPath(root)
await mkdir(dirname(manifestPath), { recursive: true })
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`)
}
async function writeInstalledManifest(
root: string,
updatedAt = manifest.updatedAt,
): Promise<void> {
const manifestPath = getInstalledManifestPath(root)
await mkdir(dirname(manifestPath), { recursive: true })
await writeFile(
manifestPath,
`${JSON.stringify({ ...manifest, updatedAt })}\n`,
)
}
async function readInstalledUpdatedAt(root: string): Promise<string> {
const raw = await readFile(getInstalledManifestPath(root), 'utf8')
return (JSON.parse(raw) as VmManifest).updatedAt
}
async function prepareReadySsh(
limaHome: string,
logPath: string,
): Promise<string> {
await writeSshConfig(limaHome)
return fakeSsh({}, logPath)
}
async function prepareFailingSsh(
limaHome: string,
logPath: string,
): Promise<string> {
await writeSshConfig(limaHome)
return fakeSsh(
{
stderr:
'rootless containerd not running? stat /run/user/501/containerd-rootless: no such file or directory',
exit: 1,
},
logPath,
)
}
async function writeSshConfig(limaHome: string): Promise<void> {
await mkdir(join(limaHome, VM_NAME), { recursive: true })
await writeFile(join(limaHome, VM_NAME, 'ssh.config'), '')
}
async function fakeRootfulThenReadySsh(
root: string,
logPath: string,
): Promise<string> {
const path = join(root, 'ssh-rootful-then-ready')
const counterPath = join(root, 'ssh-rootful-then-ready.count')
const body = `#!/usr/bin/env bash
set -u
echo "ARGS:$*" >> "${logPath}"
count="$(cat "${counterPath}" 2>/dev/null || echo 0)"
next=$((count + 1))
printf '%s' "$next" > "${counterPath}"
case "$count" in
0)
echo "rootless containerd not running" >&2
exit 1
;;
1)
printf 'runtime:containerd\\n'
exit 0
;;
*)
exit 0
;;
esac
`
await writeFile(path, body)
await chmod(path, 0o755)
return path
}

View File

@@ -3,7 +3,7 @@
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
import { afterEach, describe, expect, it, mock } from 'bun:test'
const config = {
cdpPort: 9222,
@@ -19,87 +19,87 @@ const config = {
describe('Application.start', () => {
afterEach(() => {
mock.restore()
mock.clearAllMocks()
})
it('starts with the CDP backend only', async () => {
const apiServer = await import('../src/api/server')
const browserModule = await import('../src/browser/browser')
const cdpModule = await import('../src/browser/backends/cdp')
const browserosDir = await import('../src/lib/browseros-dir')
const dbModule = await import('../src/lib/db')
const identityModule = await import('../src/lib/identity')
const loggerModule = await import('../src/lib/logger')
const metricsModule = await import('../src/lib/metrics')
const sentryModule = await import('../src/lib/sentry')
const soulModule = await import('../src/lib/soul')
const openclawService = await import(
'../src/api/services/openclaw/openclaw-service'
)
const migrateModule = await import('../src/skills/migrate')
const remoteSyncModule = await import('../src/skills/remote-sync')
const createHttpServer = spyOn(apiServer, 'createHttpServer')
createHttpServer.mockImplementation(async () => ({}) as never)
const createHttpServer = mock(async () => ({}))
const cdpConnect = mock(async () => {})
spyOn(cdpModule.CdpBackend.prototype, 'connect').mockImplementation(
cdpConnect,
)
spyOn(browserosDir, 'cleanOldSessions').mockImplementation(async () => {})
spyOn(browserosDir, 'ensureBrowserosDir').mockImplementation(async () => {})
spyOn(browserosDir, 'writeServerConfig').mockImplementation(async () => {})
spyOn(browserosDir, 'removeServerConfigSync').mockImplementation(() => {})
spyOn(dbModule, 'initializeDb').mockImplementation(() => ({}) as never)
spyOn(identityModule.identity, 'initialize').mockImplementation(() => {})
spyOn(identityModule.identity, 'getBrowserOSId').mockImplementation(
() => 'browseros-id',
)
const loggerInfo = spyOn(loggerModule.logger, 'info').mockImplementation(
() => {},
)
const loggerWarn = spyOn(loggerModule.logger, 'warn').mockImplementation(
() => {},
)
spyOn(loggerModule.logger, 'debug').mockImplementation(() => {})
const loggerError = spyOn(loggerModule.logger, 'error').mockImplementation(
() => {},
)
spyOn(loggerModule.logger, 'setLogFile').mockImplementation(() => {})
spyOn(metricsModule.metrics, 'initialize').mockImplementation(() => {})
spyOn(metricsModule.metrics, 'isEnabled').mockImplementation(() => true)
spyOn(metricsModule.metrics, 'log').mockImplementation(() => {})
spyOn(sentryModule.Sentry, 'setContext').mockImplementation(() => {})
spyOn(sentryModule.Sentry, 'setUser').mockImplementation(() => {})
spyOn(sentryModule.Sentry, 'captureException').mockImplementation(() => {})
spyOn(soulModule, 'seedSoulTemplate').mockImplementation(async () => {})
spyOn(migrateModule, 'migrateBuiltinSkills').mockImplementation(
async () => {},
)
spyOn(remoteSyncModule, 'syncBuiltinSkills').mockImplementation(
async () => {},
)
spyOn(remoteSyncModule, 'startSkillSync').mockImplementation(() => {})
spyOn(remoteSyncModule, 'stopSkillSync').mockImplementation(() => {})
spyOn(openclawService, 'configureVmRuntime').mockImplementation(
() =>
({
tryAutoStart: async () => {},
}) as never,
)
spyOn(openclawService, 'configureOpenClawService').mockImplementation(
() =>
({
tryAutoStart: async () => {},
}) as never,
)
const browserCtor = mock(() => {})
const loggerInfo = mock(() => {})
const loggerWarn = mock(() => {})
const loggerDebug = mock(() => {})
const loggerError = mock(() => {})
mock.module('../src/api/server', () => ({
createHttpServer,
}))
mock.module('../src/browser/backends/cdp', () => ({
CdpBackend: class {
async connect(): Promise<void> {
await cdpConnect()
}
},
}))
mock.module('../src/browser/browser', () => ({
Browser: class {
constructor(cdp: unknown) {
browserCtor(cdp)
}
},
}))
mock.module('../src/lib/browseros-dir', () => ({
cleanOldSessions: mock(async () => {}),
ensureBrowserosDir: mock(async () => {}),
removeServerConfigSync: mock(() => {}),
writeServerConfig: mock(async () => {}),
}))
mock.module('../src/lib/db', () => ({
initializeDb: mock(() => ({})),
}))
mock.module('../src/lib/identity', () => ({
identity: {
initialize: mock(() => {}),
getBrowserOSId: mock(() => 'browseros-id'),
},
}))
mock.module('../src/lib/logger', () => ({
logger: {
setLogFile: mock(() => {}),
info: loggerInfo,
warn: loggerWarn,
debug: loggerDebug,
error: loggerError,
},
}))
mock.module('../src/lib/metrics', () => ({
metrics: {
initialize: mock(() => {}),
isEnabled: mock(() => true),
log: mock(() => {}),
},
}))
mock.module('../src/lib/sentry', () => ({
Sentry: {
setContext: mock(() => {}),
setUser: mock(() => {}),
captureException: mock(() => {}),
},
}))
mock.module('../src/lib/soul', () => ({
seedSoulTemplate: mock(async () => {}),
}))
mock.module('../src/skills/migrate', () => ({
migrateBuiltinSkills: mock(async () => {}),
}))
mock.module('../src/skills/remote-sync', () => ({
startSkillSync: mock(() => {}),
stopSkillSync: mock(() => {}),
syncBuiltinSkills: mock(async () => {}),
}))
mock.module('../src/tools/registry', () => ({
registry: {
names: () => ['test_tool'],
},
}))
const { Application } = await import('../src/main')
const app = new Application(config)
@@ -107,14 +107,9 @@ describe('Application.start', () => {
await app.start()
expect(cdpConnect).toHaveBeenCalledTimes(1)
expect(browserCtor).toHaveBeenCalledTimes(1)
expect(createHttpServer).toHaveBeenCalledTimes(1)
expect(createHttpServer.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
browser: expect.any(browserModule.Browser),
}),
)
expect(createHttpServer.mock.calls[0]?.[0]).not.toHaveProperty('controller')
expect(loggerInfo).toHaveBeenCalled()
expect(loggerWarn).not.toHaveBeenCalled()
expect(loggerError).not.toHaveBeenCalled()
})

View File

@@ -1,114 +0,0 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { appendFile, mkdir, rm } from 'node:fs/promises'
import {
getLazyMonitoringRunDir,
getLazyMonitoringRunsDir,
} from '../src/lib/browseros-dir'
import {
InvalidMonitoringRunIdError,
isValidMonitoringRunId,
MonitoringStorage,
} from '../src/monitoring/storage'
const createdRunDirs = new Set<string>()
afterEach(async () => {
await Promise.all(
[...createdRunDirs].map(async (runId) => {
await rm(getLazyMonitoringRunDir(runId), { recursive: true, force: true })
}),
)
createdRunDirs.clear()
})
describe('MonitoringStorage run id validation', () => {
it('accepts UUID monitoring run ids', () => {
expect(isValidMonitoringRunId('123e4567-e89b-12d3-a456-426614174000')).toBe(
true,
)
})
it('rejects path traversal run ids', async () => {
expect(isValidMonitoringRunId('../../secret')).toBe(false)
const storage = new MonitoringStorage()
await expect(storage.readContext('../../secret')).rejects.toBeInstanceOf(
InvalidMonitoringRunIdError,
)
})
it('preserves valid JSONL records when one line is malformed', async () => {
const runId = '123e4567-e89b-12d3-a456-426614174001'
createdRunDirs.add(runId)
const storage = new MonitoringStorage()
await storage.writeContext({
monitoringSessionId: runId,
agentId: 'test-agent',
sessionKey: 'session-1',
originalPrompt: 'Inspect browser state safely',
chatHistory: [{ role: 'user', content: 'Inspect browser state safely' }],
startedAt: new Date().toISOString(),
source: 'debug',
})
await appendFile(
`${getLazyMonitoringRunDir(runId)}/tool-calls.jsonl`,
[
JSON.stringify({
monitoringSessionId: runId,
agentId: 'test-agent',
toolCallId: 'tool-1',
toolName: 'list_windows',
source: 'browser-tool',
args: {},
startedAt: '2026-04-20T15:22:49.817Z',
finishedAt: '2026-04-20T15:22:49.818Z',
durationMs: 1,
}),
'{"broken":',
JSON.stringify({
monitoringSessionId: runId,
agentId: 'test-agent',
toolCallId: 'tool-2',
toolName: 'take_snapshot',
source: 'browser-tool',
args: {},
startedAt: '2026-04-20T15:22:50.817Z',
finishedAt: '2026-04-20T15:22:50.818Z',
durationMs: 1,
}),
'',
].join('\n'),
)
const toolCalls = await storage.readToolCalls(runId)
expect(toolCalls).toHaveLength(2)
expect(toolCalls.map((record) => record.toolCallId)).toEqual([
'tool-1',
'tool-2',
])
})
it('skips non-uuid directories when listing run ids', async () => {
const validRunId = '123e4567-e89b-12d3-a456-426614174002'
createdRunDirs.add(validRunId)
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
await mkdir(getLazyMonitoringRunDir(validRunId), { recursive: true })
await mkdir(`${getLazyMonitoringRunsDir()}/not-a-uuid`, {
recursive: true,
})
const storage = new MonitoringStorage()
const runIds = await storage.listRunIds()
expect(runIds).toContain(validRunId)
expect(runIds).not.toContain('not-a-uuid')
await rm(`${getLazyMonitoringRunsDir()}/not-a-uuid`, {
recursive: true,
force: true,
})
})
})

View File

@@ -1,29 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it } from 'bun:test'
import { buildTestCommand, withTestEnv } from './__helpers__/run-test-group'
describe('withTestEnv', () => {
it('defaults NODE_ENV to test when absent', () => {
expect(withTestEnv({ PATH: '/usr/bin' }).NODE_ENV).toBe('test')
})
it('preserves an explicit NODE_ENV', () => {
expect(withTestEnv({ NODE_ENV: 'production' }).NODE_ENV).toBe('production')
})
})
describe('buildTestCommand', () => {
it('preloads the test env bootstrap before running targets', () => {
expect(buildTestCommand(['./tests/api'])).toEqual([
process.execPath,
'--env-file=.env.development',
'test',
'--preload=./tests/__helpers__/test-env.ts',
'./tests/api',
])
})
})

View File

@@ -1,7 +1,6 @@
import { afterAll, describe, it } from 'bun:test'
import { describe, it } from 'bun:test'
import assert from 'node:assert'
import type { Browser } from '../../src/browser/browser'
import { disposeSemanticPipeline } from '../../src/tools/acl/acl-embeddings'
import { executeTool, type ToolContext } from '../../src/tools/framework'
import {
check,
@@ -17,9 +16,7 @@ import {
} from '../../src/tools/input'
import { close_page, navigate_page, new_page } from '../../src/tools/navigation'
import { evaluate_script, take_snapshot } from '../../src/tools/snapshot'
import { cleanupWithBrowser, withBrowser } from '../__helpers__/with-browser'
process.env.ACL_EMBEDDING_DISABLE = 'true'
import { withBrowser } from '../__helpers__/with-browser'
function textOf(result: {
content: { type: string; text?: string }[]
@@ -55,72 +52,6 @@ function findElementId(snapshotText: string, label: string): number {
return Number.parseInt(match[1], 10)
}
async function pointInsideElement(
ctx: ToolContext,
pageId: number,
elementDomId: string,
): Promise<{ x: number; y: number }> {
const pointResult = await executeTool(
evaluate_script,
{
page: pageId,
expression: `(() => {
const el = document.getElementById(${JSON.stringify(elementDomId)});
if (!el) return null;
const rect = el.getBoundingClientRect();
const insetX = Math.max(1, Math.min(10, Math.floor(rect.width / 4)));
const insetY = Math.max(1, Math.min(10, Math.floor(rect.height / 4)));
const candidates = [
{
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
},
{
x: Math.round(rect.left + insetX),
y: Math.round(rect.top + insetY),
},
{
x: Math.round(rect.right - insetX),
y: Math.round(rect.top + insetY),
},
{
x: Math.round(rect.left + insetX),
y: Math.round(rect.bottom - insetY),
},
{
x: Math.round(rect.right - insetX),
y: Math.round(rect.bottom - insetY),
},
];
for (const candidate of candidates) {
const target = document.elementFromPoint(candidate.x, candidate.y);
if (target && (target === el || el.contains(target))) {
return { ...candidate, matched: true, hitId: target.id || null };
}
}
const fallback = candidates[0];
const fallbackTarget = document.elementFromPoint(fallback.x, fallback.y);
return {
...fallback,
matched: false,
hitId: fallbackTarget instanceof Element ? fallbackTarget.id || null : null,
};
})()`,
},
ctx,
AbortSignal.timeout(30_000),
)
const point = structuredOf<{
value: { x: number; y: number; matched: boolean; hitId: string | null }
} | null>(pointResult)?.value
assert.ok(point, `Expected a point for #${elementDomId}`)
assert.ok(
point.matched,
`Expected coordinates inside #${elementDomId}, got ${point.hitId ?? 'null'}`,
)
return { x: point.x, y: point.y }
}
const FORM_PAGE = `data:text/html,${encodeURIComponent(`<!DOCTYPE html>
<html><body>
<h1>Test Form</h1>
@@ -158,11 +89,6 @@ const FORM_PAGE = `data:text/html,${encodeURIComponent(`<!DOCTYPE html>
</script>
</body></html>`)}`
afterAll(async () => {
await disposeSemanticPipeline()
await cleanupWithBrowser()
})
describe('input tools', () => {
it('fill types text into an input', async () => {
await withBrowser(async ({ execute }) => {
@@ -484,7 +410,7 @@ describe('input tools', () => {
{
id: 'submit-rule',
sitePattern: '*',
textMatch: 'Submit',
description: 'submit',
enabled: true,
},
]
@@ -511,7 +437,7 @@ describe('input tools', () => {
{
id: 'submit-rule',
sitePattern: '*',
textMatch: 'Submit',
description: 'submit',
enabled: true,
},
{
@@ -531,7 +457,24 @@ describe('input tools', () => {
)
const pageId = pageIdOf(newResult)
const buttonPoint = await pointInsideElement(ctx, pageId, 'submit-btn')
const buttonCenter = await executeTool(
evaluate_script,
{
page: pageId,
expression: `(() => {
const rect = document.getElementById('submit-btn').getBoundingClientRect();
return {
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
};
})()`,
},
ctx,
AbortSignal.timeout(30_000),
)
const buttonPoint = structuredOf<{ value: { x: number; y: number } }>(
buttonCenter,
).value
const blockedClick = await executeTool(
click_at,
@@ -549,7 +492,24 @@ describe('input tools', () => {
},
]
const inputPoint = await pointInsideElement(ctx, pageId, 'name')
const inputCenter = await executeTool(
evaluate_script,
{
page: pageId,
expression: `(() => {
const rect = document.getElementById('name').getBoundingClientRect();
return {
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
};
})()`,
},
ctx,
AbortSignal.timeout(30_000),
)
const inputPoint = structuredOf<{ value: { x: number; y: number } }>(
inputCenter,
).value
const blockedType = await executeTool(
type_at,

View File

@@ -156,7 +156,7 @@
},
"apps/server": {
"name": "@browseros/server",
"version": "0.0.88",
"version": "0.0.85",
"bin": {
"browseros-server": "./src/index.ts",
},
@@ -232,17 +232,6 @@
"zod": "^3.x",
},
},
"packages/build-tools": {
"name": "@browseros/build-tools",
"version": "0.0.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.933.0",
"@browseros/shared": "workspace:*",
},
"devDependencies": {
"@types/node": "^24.3.3",
},
},
"packages/cdp-protocol": {
"name": "@browseros/cdp-protocol",
"version": "0.0.1",
@@ -474,8 +463,6 @@
"@browseros/agent": ["@browseros/agent@workspace:apps/agent"],
"@browseros/build-tools": ["@browseros/build-tools@workspace:packages/build-tools"],
"@browseros/cdp-protocol": ["@browseros/cdp-protocol@workspace:packages/cdp-protocol"],
"@browseros/eval": ["@browseros/eval@workspace:apps/eval"],
@@ -4502,8 +4489,6 @@
"@browseros/agent/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"@browseros/build-tools/@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1014.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-node": "^3.972.24", "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", "@aws-sdk/middleware-expect-continue": "^3.972.8", "@aws-sdk/middleware-flexible-checksums": "^3.974.3", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-location-constraint": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/middleware-ssec": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/signature-v4-multi-region": "^3.996.11", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-blob-browser": "^4.2.13", "@smithy/hash-node": "^4.2.12", "@smithy/hash-stream-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/md5-js": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-0XLrOT4Cm3NEhhiME7l/8LbTXS4KdsbR4dSrY207KNKTcHLLTZ9EXt4ZpgnTfLvWQF3pGP2us4Zi1fYLo0N+Ow=="],
"@browseros/eval/@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1014.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-node": "^3.972.24", "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", "@aws-sdk/middleware-expect-continue": "^3.972.8", "@aws-sdk/middleware-flexible-checksums": "^3.974.3", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-location-constraint": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/middleware-ssec": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/signature-v4-multi-region": "^3.996.11", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-blob-browser": "^4.2.13", "@smithy/hash-node": "^4.2.12", "@smithy/hash-stream-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/md5-js": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-0XLrOT4Cm3NEhhiME7l/8LbTXS4KdsbR4dSrY207KNKTcHLLTZ9EXt4ZpgnTfLvWQF3pGP2us4Zi1fYLo0N+Ow=="],
"@browseros/eval/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
@@ -5228,100 +5213,6 @@
"@browseros/agent/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.24", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-ini": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/crc64-nvme": "^3.972.5", "@aws-sdk/types": "^3.973.6", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-fB7FNLH1+VPUs0QL3PLrHW+DD4gKu6daFgWtyq3R0Y0Lx8DLZPvyGAxCZNFBxH+M2xt9KvBJX6USwjuqvitmCQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-50QgHGPQAb2veqFOmTF1A3GsAklLHZXL47KbY35khIkfbXH5PLvqpEc/gOAEBPj/yFxrlgxz/8mqWcWTNxBkwQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-dLTWy6IfAMhNiSEvMr07g/qZ54be6pLqlxVblbF6AzafmmGAzMMj8qMoY9B4+YgT+gY9IcuxZslNh03L6PyMCQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/config-resolver": "^4.4.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.11", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-SKgZY7x6AloLUXO20FJGnkKJ3a6CXzNDt6PYs2yqoPzgU0xKWcUoGGJGEBTsfM5eihKW42lbwp+sXzACLbSsaA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.10", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-E99zeTscCc+pTMfsvnfi6foPpKmdD1cZfOC7/P8UUrjsoQdg9VEWPRD+xdFduKnfPXwcvby58AlO9jwwF6U96g=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/config-resolver": ["@smithy/config-resolver@4.4.13", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/core": ["@smithy/core@3.23.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.15", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.13", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/hash-node": ["@smithy/hash-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/md5-js": ["@smithy/md5-js@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.27", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-serde": "^4.2.15", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.44", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/service-error-classification": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.15", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.12", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.0", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/protocol-http": ["@smithy/protocol-http@5.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/smithy-client": ["@smithy/smithy-client@4.12.7", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-stack": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/url-parser": ["@smithy/url-parser@4.2.12", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.43", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.47", "", { "dependencies": { "@smithy/config-resolver": "^4.4.13", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-middleware": ["@smithy/util-middleware@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-retry": ["@smithy/util-retry@4.2.12", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-stream": ["@smithy/util-stream@4.5.20", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-waiter": ["@smithy/util-waiter@4.2.13", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.24", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-ini": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw=="],
@@ -5706,70 +5597,6 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BkAfKq8Bd4shCtec1usNz//urPJF/SZy14qJyxkSaRJQ/Vv1gVh0VZSTmS7aE6aLMELkFV5wHHrS9ZcdG8Kxsg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-4XZ3+Gu5DY8/n8zQFHBgcKTF7hWQl42G6CY9xfXVo2d25FM/lYkpmuzhYopYoPL1ITWkJ2OSBQfYEu5JRfHOhA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-login": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-PZLSmU0JFpNCDFReidBezsgL5ji9jOBry8CnZdw4Jj6d0K2z3Ftnp44NXgADqYx5BLMu/ZHujfeJReaDoV+IwQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-nRxbeOJ1E1gVA0lNQezuMVndx+ZcuyaW/RB05pUsznN5BxykSlH6KkZ/7Ca/ubJf3i5N3p0gwNO5zgPSCzj+ww=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/token-providers": "3.1014.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-APUccADuYPLL0f2htpM8Z4czabSmHOdo4r41W6lKEZdy++cNJ42Radqy6x4TopENzr3hR6WYMyhiuiqtbf/nAA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-H5JNqtIwOu/feInmMMWcK0dL5r897ReEn7n2m16Dd0DPD9gA2Hg8Cq4UDzZ/9OzaLh/uqBM6seixz0U6Fi2Eag=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-bucket-endpoint/@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-flexible-checksums/@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.5", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3/@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-browser/@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-node/@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/fetch-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-endpoint/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-config-provider/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-config-provider/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-http-handler/@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/url-parser/@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-browser/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-node/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-waiter/@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
@@ -5878,22 +5705,6 @@
"wxt/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1014.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-gHTHNUoaOGNrSWkl32A7wFsU78jlNTlqMccLu0byUk5CysYYXaxNMIonIVr4YcykC7vgtDS5ABuz83giy6fzJA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-browser/@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-node/@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg=="],
@@ -5922,8 +5733,6 @@
"publish-browser-extension/listr2/cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
"@google/genai/google-auth-library/gaxios/rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],

View File

@@ -13,7 +13,6 @@
"dev:watch": "./tools/dev/run.sh watch",
"dev:watch:new": "./tools/dev/run.sh watch --new",
"dev:manual": "./tools/dev/run.sh watch --manual",
"dev:setup": "./tools/dev/setup.sh",
"test:env": "./tools/dev/run.sh test",
"test:cleanup": "./tools/dev/run.sh cleanup",
"start:server": "bun run --filter @browseros/server --elide-lines=0 start",
@@ -28,17 +27,10 @@
"build:agent": "bun run codegen:agent && bun run --filter @browseros/agent build",
"build:agent-sdk": "bun run --filter @browseros-ai/agent-sdk build",
"codegen:agent": "bun run --filter @browseros/agent codegen",
"test": "bun run test:all",
"test:all": "bun run test:server && bun run test:agent && bun run test:eval && bun run test:agent-sdk && bun run test:build",
"test:server": "bun run --filter @browseros/server test",
"test": "bun run test:tools && bun run test:integration",
"test:tools": "bun run --filter @browseros/server test:tools",
"test:cdp": "bun run --filter @browseros/server test:cdp",
"test:integration": "bun run --filter @browseros/server test:integration",
"test:sdk": "bun run --filter @browseros/server test:sdk",
"test:agent": "bun run ./scripts/run-bun-test.ts ./apps/agent",
"test:eval": "bun run ./scripts/run-bun-test.ts ./apps/eval/tests",
"test:agent-sdk": "bun run ./scripts/run-bun-test.ts ./packages/agent-sdk",
"test:build": "bun run ./scripts/run-bun-test.ts ./scripts/build",
"test:sdk": "echo 'SDK tests disabled: test environment does not provide the extract/verify LLM service'",
"typecheck": "bun run --filter '*' typecheck",
"lint": "bunx biome check",
"lint:fix": "bunx biome check --write --unsafe",

View File

@@ -1,11 +0,0 @@
# R2 / Cloudflare object storage - required by upload and publish jobs
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=browseros
# Public CDN base - used by cache:sync to GET manifest and artifacts
R2_PUBLIC_BASE_URL=https://cdn.browseros.com
# Dev mode routes cache to ~/.browseros-dev/cache/; unset for ~/.browseros/cache/
NODE_ENV=development

View File

@@ -1,78 +0,0 @@
# @browseros/build-tools
Builds agent image tarballs, publishes release artifacts to R2, and hydrates the local dev cache for agent tarballs.
The BrowserOS VM is defined by a committed Lima template at `template/browseros-vm.yaml`. There is no custom disk build step; `limactl` consumes the template directly at runtime.
## Setup
```bash
cp packages/build-tools/.env.sample packages/build-tools/.env
bun install
```
## Dev loop against the Lima template
Requires `limactl` on PATH. It is bundled with the server; for bare-worktree use, install Lima with Homebrew.
```bash
brew install lima
```
```bash
limactl start \
--name browseros-vm-dev \
packages/browseros-agent/packages/build-tools/template/browseros-vm.yaml
limactl shell browseros-vm-dev nerdctl info
SOCK="$(limactl list browseros-vm-dev --format '{{.Dir}}')/sock/containerd.sock"
test -S "$SOCK"
bun run --filter @browseros/build-tools build:tarball -- --agent openclaw --arch arm64
limactl shell browseros-vm-dev nerdctl load -i "$(ls dist/images/openclaw-*-arm64.tar.gz | head -1)"
limactl delete --force browseros-vm-dev
```
## Build an agent tarball
The BrowserOS VM uses containerd + nerdctl. This host-side tarball builder still requires `podman` to pull and save OCI archives for release packaging.
```bash
bun run --filter @browseros/build-tools build:tarball -- --agent openclaw --arch arm64
```
## Smoke test an agent tarball
```bash
bun run --filter @browseros/build-tools smoke:tarball -- --agent openclaw --arch arm64 --tarball ./dist/images/openclaw-2026.4.12-arm64.tar.gz
```
## Emit a manifest
```bash
bun run --filter @browseros/build-tools emit-manifest -- --dist-dir packages/build-tools/dist
```
Publish workflows can update one agent slice at a time. Sliced publishing requires an existing R2 `vm/manifest.json` baseline; bootstrap first releases with `--slice full`.
```bash
bun run --filter @browseros/build-tools emit-manifest -- --slice agents:openclaw --merge-from https://cdn.browseros.com/vm/manifest.json
```
## Sync the dev cache
```bash
NODE_ENV=development bun run --filter @browseros/build-tools cache:sync
```
Pulls the published manifest and tarballs from R2 (`cdn.browseros.com/vm/`). Development cache files land under `~/.browseros-dev/cache/vm/images/`. Production-mode cache files land under `~/.browseros/cache/vm/images/`.
## Seed the dev cache from a local build
```bash
NODE_ENV=development bun run --filter @browseros/build-tools dev:seed:tarball
```
`dev:seed:tarball` hardcodes `arm64` (all devs are on Apple Silicon), builds the configured agent tarball, skips R2 entirely, and writes an arm64-only manifest + tarball into `~/.browseros-dev/cache/vm/`. It refuses to run unless `NODE_ENV=development`. Use this when you want to test the server against the latest configured agent tarball without publishing.

View File

@@ -1,9 +0,0 @@
{
"agents": [
{
"name": "openclaw",
"image": "ghcr.io/openclaw/openclaw",
"version": "2026.4.12"
}
]
}

View File

@@ -1,25 +0,0 @@
{
"name": "@browseros/build-tools",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "BrowserOS release artifact producer and dev cache sync",
"scripts": {
"build:tarball": "bun run scripts/build-tarball.ts",
"emit-manifest": "bun run scripts/emit-manifest.ts",
"upload": "bun run scripts/upload-to-r2.ts",
"download": "bun run scripts/download-from-r2.ts",
"cache:sync": "bun run scripts/cache-sync.ts",
"dev:seed:tarball": "bun run scripts/seed-dev-agent-tarball.ts",
"smoke:tarball": "bun run scripts/smoke-tarball.ts",
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.933.0",
"@browseros/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^24.3.3"
}
}

View File

@@ -1,92 +0,0 @@
#!/usr/bin/env bun
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { parseArgs } from 'node:util'
import { parseArch, podmanArch } from './common/arch'
import { type Bundle, tarballKey } from './common/manifest'
import { sha256File } from './common/sha256'
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
agent: { type: 'string' },
arch: { type: 'string' },
'output-dir': { type: 'string', default: './dist/images' },
},
})
if (!values.agent || !values.arch) {
console.error(
'usage: build:tarball -- --agent <name> --arch <arm64|x64> [--output-dir ./dist/images]',
)
process.exit(1)
}
const arch = parseArch(values.arch)
const outDir = values['output-dir']
await mkdir(outDir, { recursive: true })
const pkgRoot = path.resolve(import.meta.dir, '..')
const bundle = JSON.parse(
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
) as Bundle
const agent = bundle.agents.find(({ name }) => name === values.agent)
if (!agent) throw new Error(`unknown agent: ${values.agent}`)
const ref = `${agent.image}:${agent.version}`
const tarballPath = path.join(
outDir,
path.basename(tarballKey(agent.name, agent.version, arch)),
)
const tarPath = tarballPath.slice(0, -'.gz'.length)
await rm(tarballPath, { force: true })
await rm(`${tarballPath}.sha256`, { force: true })
await rm(tarPath, { force: true })
await spawnChecked([
'podman',
'pull',
'--os',
'linux',
'--arch',
podmanArch(arch),
ref,
])
await spawnChecked([
'podman',
'save',
'--format=oci-archive',
'--output',
tarPath,
ref,
])
await spawnChecked(['gzip', '-9', '-f', tarPath])
const sha = await sha256File(tarballPath)
const size = (await stat(tarballPath)).size
await writeFile(
`${tarballPath}.sha256`,
`${sha} ${path.basename(tarballPath)}\n`,
)
console.log(
JSON.stringify(
{
key: tarballKey(agent.name, agent.version, arch),
path: tarballPath,
sha256: sha,
sizeBytes: size,
},
null,
2,
),
)
async function spawnChecked(argv: string[]): Promise<void> {
const proc = Bun.spawn(argv, {
stdout: 'inherit',
stderr: 'inherit',
})
const code = await proc.exited
if (code !== 0) throw new Error(`${argv[0]} exited ${code}`)
}

View File

@@ -1,155 +0,0 @@
#!/usr/bin/env bun
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { homedir, arch as hostArch } from 'node:os'
import path from 'node:path'
import { parseArgs } from 'node:util'
import { PATHS } from '@browseros/shared/constants/paths'
import { ARCHES, type Arch } from './common/arch'
import { fetchWithTimeout } from './common/fetch'
import type { AgentManifest, Artifact } from './common/manifest'
import { verifySha256 } from './common/sha256'
type ChunkSink = ReturnType<ReturnType<typeof Bun.file>['writer']>
export interface PlanItem {
key: string
destPath: string
sha256: string
}
export function planSync(opts: {
local: AgentManifest | null
remote: AgentManifest
cacheRoot: string
arches: Arch[]
}): PlanItem[] {
const out: PlanItem[] = []
for (const arch of opts.arches) {
for (const [name, agent] of Object.entries(opts.remote.agents)) {
maybeAdd(
out,
agent.tarballs[arch],
opts.local?.agents[name]?.tarballs[arch],
opts.cacheRoot,
)
}
}
return out
}
export function selectSyncArches(
allArches: boolean,
rawHostArch = hostArch(),
): Arch[] {
if (allArches) return [...ARCHES]
if (rawHostArch === 'arm64') return ['arm64']
if (rawHostArch === 'x64' || rawHostArch === 'ia32') return ['x64']
throw new Error(`unsupported host arch: ${rawHostArch}`)
}
if (import.meta.main) {
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
'manifest-url': { type: 'string' },
'all-arches': { type: 'boolean' },
'cache-dir': { type: 'string' },
},
})
const cdnBase =
process.env.R2_PUBLIC_BASE_URL?.trim() ?? 'https://cdn.browseros.com'
const manifestUrl = values['manifest-url'] ?? `${cdnBase}/vm/manifest.json`
const cacheRoot = values['cache-dir'] ?? getCacheDir()
const arches = selectSyncArches(values['all-arches'] ?? false)
const response = await fetchWithTimeout(manifestUrl)
if (!response.ok) {
throw new Error(
`manifest fetch failed: ${manifestUrl} (${response.status})`,
)
}
const remote = (await response.json()) as AgentManifest
const localManifestPath = path.join(cacheRoot, 'vm', 'manifest.json')
const local = await readLocalManifest(localManifestPath)
const plan = planSync({ local, remote, cacheRoot, arches })
if (plan.length === 0) {
console.log('agent cache up to date')
process.exit(0)
}
console.log(`syncing ${plan.length} agent artifact(s)`)
for (const item of plan) {
await mkdir(path.dirname(item.destPath), { recursive: true })
const partial = `${item.destPath}.partial`
await downloadToFile(`${cdnBase}/${item.key}`, partial)
await verifySha256(partial, item.sha256)
await rename(partial, item.destPath)
console.log(`synced ${item.key}`)
}
await mkdir(path.dirname(localManifestPath), { recursive: true })
await writeFile(localManifestPath, `${JSON.stringify(remote, null, 2)}\n`)
console.log(`manifest written to ${localManifestPath}`)
}
function maybeAdd(
out: PlanItem[],
remote: Artifact,
local: Artifact | undefined,
cacheRoot: string,
): void {
if (local?.sha256 === remote.sha256) return
out.push({
key: remote.key,
destPath: path.join(cacheRoot, remote.key),
sha256: remote.sha256,
})
}
function getCacheDir(): string {
const dirName =
process.env.NODE_ENV === 'development'
? PATHS.DEV_BROWSEROS_DIR_NAME
: PATHS.BROWSEROS_DIR_NAME
return path.join(homedir(), dirName, PATHS.CACHE_DIR_NAME)
}
export async function readLocalManifest(
manifestPath: string,
): Promise<AgentManifest | null> {
try {
return JSON.parse(await readFile(manifestPath, 'utf8')) as AgentManifest
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
}
}
async function downloadToFile(url: string, dest: string): Promise<void> {
const response = await fetchWithTimeout(url)
if (!response.ok || !response.body) {
throw new Error(`download failed: ${url} (${response.status})`)
}
const sink = Bun.file(dest).writer()
const reader = response.body.getReader()
try {
await pumpStream(reader, sink)
} finally {
await sink.end()
}
}
async function pumpStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
sink: ChunkSink,
): Promise<void> {
for (;;) {
const { done, value } = await reader.read()
if (done) break
sink.write(value)
}
}

View File

@@ -1,12 +0,0 @@
export type Arch = 'arm64' | 'x64'
export const ARCHES: readonly Arch[] = ['arm64', 'x64']
export function parseArch(raw: string): Arch {
if (raw === 'arm64' || raw === 'x64') return raw
throw new Error(`unknown arch: ${raw} (expected arm64|x64)`)
}
export function podmanArch(arch: Arch): 'arm64' | 'amd64' {
return arch === 'x64' ? 'amd64' : 'arm64'
}

View File

@@ -1,22 +0,0 @@
export async function fetchWithTimeout(
url: string,
init: RequestInit = {},
timeoutMs = 30_000,
): Promise<Response> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
return await fetch(url, {
...init,
signal: init.signal ?? controller.signal,
})
} catch (error) {
if ((error as { name?: string }).name === 'AbortError') {
throw new Error(`fetch timed out after ${timeoutMs}ms: ${url}`)
}
throw error
} finally {
clearTimeout(timer)
}
}

View File

@@ -1,75 +0,0 @@
import { ARCHES, type Arch } from './arch'
export interface Artifact {
key: string
sha256: string
sizeBytes: number
}
export interface AgentEntry {
image: string
version: string
tarballs: Record<Arch, Artifact>
}
export interface AgentManifest {
schemaVersion: 2
updatedAt: string
agents: Record<string, AgentEntry>
}
export interface BundleAgent {
name: string
image: string
version: string
}
export interface Bundle {
agents: BundleAgent[]
}
export interface ArtifactInput {
sha256: string
sizeBytes: number
}
export interface ArtifactInputs {
agents: Record<string, Record<Arch, ArtifactInput>>
}
export function tarballKey(name: string, version: string, arch: Arch): string {
return `vm/images/${name}-${version}-${arch}.tar.gz`
}
export function buildManifest(
bundle: Bundle,
inputs: ArtifactInputs,
now: Date = new Date(),
): AgentManifest {
const agents: Record<string, AgentEntry> = {}
for (const agent of bundle.agents) {
const tarballs = {} as Record<Arch, Artifact>
for (const arch of ARCHES) {
const entry = inputs.agents[agent.name]?.[arch]
if (!entry) {
throw new Error(`missing tarball inputs for ${agent.name}/${arch}`)
}
tarballs[arch] = {
key: tarballKey(agent.name, agent.version, arch),
sha256: entry.sha256,
sizeBytes: entry.sizeBytes,
}
}
agents[agent.name] = {
image: agent.image,
version: agent.version,
tarballs,
}
}
return {
schemaVersion: 2,
updatedAt: now.toISOString(),
agents,
}
}

View File

@@ -1,96 +0,0 @@
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import {
GetObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3'
function required(name: string): string {
const value = process.env[name]?.trim()
if (!value) throw new Error(`missing env var: ${name}`)
return value
}
export function createR2Client(): S3Client {
return new S3Client({
region: 'auto',
endpoint: `https://${required('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: required('R2_ACCESS_KEY_ID'),
secretAccessKey: required('R2_SECRET_ACCESS_KEY'),
},
})
}
export function getBucket(): string {
return required('R2_BUCKET')
}
export function getCdnBase(): string {
return process.env.R2_PUBLIC_BASE_URL?.trim() ?? 'https://cdn.browseros.com'
}
export async function putFile(
client: S3Client,
bucket: string,
key: string,
filePath: string,
contentType: string,
): Promise<void> {
const { size } = await stat(filePath)
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: createReadStream(filePath),
ContentLength: size,
ContentType: contentType,
}),
)
}
export async function putBody(
client: S3Client,
bucket: string,
key: string,
body: string,
contentType: string,
): Promise<void> {
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentLength: Buffer.byteLength(body),
ContentType: contentType,
}),
)
}
export async function getBody(
client: S3Client,
bucket: string,
key: string,
): Promise<string | null> {
try {
const response = await client.send(
new GetObjectCommand({ Bucket: bucket, Key: key }),
)
const body = response.Body as
| { transformToByteArray(): Promise<Uint8Array> }
| undefined
if (!body) throw new Error(`missing response body for R2 key: ${key}`)
const bytes = await body.transformToByteArray()
return new TextDecoder().decode(bytes)
} catch (error) {
const cause = error as {
name?: string
$metadata?: { httpStatusCode?: number }
}
if (cause.name === 'NoSuchKey' || cause.$metadata?.httpStatusCode === 404) {
return null
}
throw error
}
}

Some files were not shown because too many files have changed in this diff Show More