mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
57 Commits
fix/setup
...
feat/lima-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88a84cf901 | ||
|
|
d7810fc293 | ||
|
|
46dc7f23e1 | ||
|
|
d68d139911 | ||
|
|
b710c24e43 | ||
|
|
ffae87a779 | ||
|
|
dfcae6077e | ||
|
|
ef50e6bf9e | ||
|
|
b0e95d74ed | ||
|
|
0dbdf0e4b6 | ||
|
|
6b6ed1582c | ||
|
|
9d232ecfe4 | ||
|
|
ac7f98b54b | ||
|
|
f8ed761d66 | ||
|
|
9fae0e419e | ||
|
|
06a88b23c5 | ||
|
|
915c7c0e33 | ||
|
|
8bd1061d71 | ||
|
|
fcc7c45c79 | ||
|
|
7dda000d9c | ||
|
|
31d463c441 | ||
|
|
5a26c1c565 | ||
|
|
2704d40f4e | ||
|
|
a4ef04ae15 | ||
|
|
fe2c0da92f | ||
|
|
b70c2c2e88 | ||
|
|
c637245b7a | ||
|
|
6acdbd5a51 | ||
|
|
d4a22fcc2e | ||
|
|
a3764e7599 | ||
|
|
c656f6236c | ||
|
|
4d660874ad | ||
|
|
819887a2c5 | ||
|
|
114d5e3a9f | ||
|
|
ecba7de221 | ||
|
|
123a13fe62 | ||
|
|
5ccdbaf87f | ||
|
|
0650f21c80 | ||
|
|
e80ec467f4 | ||
|
|
41374439c4 | ||
|
|
ad99cd6cc1 | ||
|
|
47fc9e1292 | ||
|
|
2a61dcbc58 | ||
|
|
f5a2b7315c | ||
|
|
6de3b3422c | ||
|
|
224b6cd3a8 | ||
|
|
7baee8d57e | ||
|
|
e8e8c36fdb | ||
|
|
3810005457 | ||
|
|
688f7962cb | ||
|
|
526d784d82 | ||
|
|
331fec07e6 | ||
|
|
0652ee8ca8 | ||
|
|
156f5dbc5d | ||
|
|
ebd3200cfe | ||
|
|
4172daa130 | ||
|
|
c1b1e53a86 |
157
.github/workflows/build-agent.yml
vendored
Normal file
157
.github/workflows/build-agent.yml
vendored
Normal file
@@ -0,0 +1,157 @@
|
||||
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"
|
||||
77
.github/workflows/test.yml
vendored
77
.github/workflows/test.yml
vendored
@@ -30,12 +30,54 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- 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
|
||||
- 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
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -48,6 +90,7 @@ jobs:
|
||||
run: bun ci
|
||||
|
||||
- name: Resolve BrowserOS cache key
|
||||
if: matrix.needs_browser == true
|
||||
id: browseros-cache-key
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -62,6 +105,7 @@ 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:
|
||||
@@ -69,13 +113,14 @@ jobs:
|
||||
key: ${{ steps.browseros-cache-key.outputs.key }}
|
||||
|
||||
- name: Download BrowserOS
|
||||
if: steps.browseros-cache.outputs.cache-hit != 'true'
|
||||
if: matrix.needs_browser == true && 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'
|
||||
@@ -96,16 +141,23 @@ 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
|
||||
cd apps/server
|
||||
bun run test:cleanup
|
||||
bun --env-file=.env.development test "${{ matrix.test_path }}" --reporter=junit --reporter-outfile="../../${{ matrix.junit_path }}"
|
||||
${{ matrix.command }}
|
||||
exit_code=$?
|
||||
cd ../..
|
||||
if [ ! -f "${{ matrix.junit_path }}" ]; then
|
||||
cat > "${{ matrix.junit_path }}" <<EOF
|
||||
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
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites tests="1" failures="1">
|
||||
<testsuite name="${{ matrix.suite }}" tests="1" failures="1">
|
||||
@@ -115,6 +167,7 @@ jobs:
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
**/.DS_Store
|
||||
**.auctor/**
|
||||
.auctor.json
|
||||
.gcs_entries
|
||||
**/dmg
|
||||
**/env
|
||||
|
||||
1
packages/browseros-agent/.gitignore
vendored
1
packages/browseros-agent/.gitignore
vendored
@@ -14,6 +14,7 @@ lerna-debug.log*
|
||||
# Ignore all .env files except .env.example
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
!**/.env.sample
|
||||
!**/.env.production.example
|
||||
|
||||
|
||||
|
||||
@@ -218,3 +218,9 @@ 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.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
buildChatHistoryFromTurns,
|
||||
chatWithAgent,
|
||||
type OpenClawStreamEvent,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
@@ -187,6 +188,7 @@ 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(),
|
||||
@@ -207,6 +209,7 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
||||
agentId,
|
||||
text.trim(),
|
||||
sessionKeyRef.current,
|
||||
history,
|
||||
abortController.signal,
|
||||
)
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -20,7 +20,11 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { consumeSSEStream } from '@/lib/sse'
|
||||
import { chatWithAgent, type OpenClawStreamEvent } from './useOpenClaw'
|
||||
import {
|
||||
buildChatHistoryFromTurns,
|
||||
chatWithAgent,
|
||||
type OpenClawStreamEvent,
|
||||
} from './useOpenClaw'
|
||||
|
||||
interface ToolEntry {
|
||||
id: string
|
||||
@@ -204,6 +208,7 @@ 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(),
|
||||
@@ -225,6 +230,7 @@ export const AgentChat: FC<AgentChatProps> = ({
|
||||
agentId,
|
||||
text,
|
||||
sessionKeyRef.current,
|
||||
history,
|
||||
abortController.signal,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import type {
|
||||
BrowserOSCustomRoleInput,
|
||||
BrowserOSRoleBoundary,
|
||||
} from '@browseros/shared/types/role-aware-agents'
|
||||
import {
|
||||
AlertCircle,
|
||||
Cpu,
|
||||
@@ -35,53 +31,25 @@ 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 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 LIFECYCLE_BANNER_COPY: Record<GatewayLifecycleAction, string> = {
|
||||
setup: 'Setting up OpenClaw...',
|
||||
start: 'Starting gateway...',
|
||||
stop: 'Stopping gateway...',
|
||||
restart: 'Restarting gateway...',
|
||||
reconnect: 'Restoring gateway connection...',
|
||||
}
|
||||
|
||||
const CONTROL_PLANE_COPY: Record<
|
||||
@@ -281,7 +249,6 @@ export const AgentsPage: FC = () => {
|
||||
loading: agentsLoading,
|
||||
error: agentsError,
|
||||
} = useOpenClawAgents(agentsQueryEnabled)
|
||||
const { roles, loading: rolesLoading, error: rolesError } = useOpenClawRoles()
|
||||
const {
|
||||
setupOpenClaw,
|
||||
createAgent,
|
||||
@@ -295,48 +262,20 @@ 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 = 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
|
||||
const compatibleProviders = getOpenClawSupportedProviders(providers)
|
||||
|
||||
useEffect(() => {
|
||||
if (compatibleProviders.length === 0) return
|
||||
@@ -355,48 +294,18 @@ export const AgentsPage: FC = () => {
|
||||
defaultProviderId,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen || roles.length === 0) return
|
||||
|
||||
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
|
||||
setNewName((current) => current || 'agent')
|
||||
}, [createOpen])
|
||||
|
||||
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 lifecyclePending = pendingGatewayAction !== null
|
||||
const inlineError = lifecyclePending
|
||||
? null
|
||||
: (error ?? statusError?.message ?? agentsError?.message ?? null)
|
||||
const lifecycleBanner = pendingGatewayAction
|
||||
? LIFECYCLE_BANNER_COPY[pendingGatewayAction]
|
||||
: null
|
||||
|
||||
const gatewayUiState = useMemo(() => {
|
||||
if (!status) {
|
||||
@@ -425,6 +334,10 @@ 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)
|
||||
@@ -462,34 +375,10 @@ 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,
|
||||
@@ -498,13 +387,6 @@ export const AgentsPage: FC = () => {
|
||||
})
|
||||
setCreateOpen(false)
|
||||
setNewName('')
|
||||
setCustomRole({
|
||||
name: '',
|
||||
shortDescription: '',
|
||||
longDescription: '',
|
||||
recommendedApps: [],
|
||||
boundaries: createDefaultCustomRoleBoundaries(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -619,7 +501,7 @@ export const AgentsPage: FC = () => {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={!gatewayUiState.canManageAgents}
|
||||
disabled={!canManageAgents}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
New Agent
|
||||
@@ -630,6 +512,13 @@ export const AgentsPage: FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lifecycleBanner && (
|
||||
<Alert>
|
||||
<Loader2 className="animate-spin" />
|
||||
<AlertTitle>{lifecycleBanner}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{inlineError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle />
|
||||
@@ -649,7 +538,7 @@ export const AgentsPage: FC = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status && gatewayUiState.controlPlaneDegraded && (
|
||||
{status && showControlPlaneDegraded && (
|
||||
<Alert
|
||||
variant={
|
||||
status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'
|
||||
@@ -701,8 +590,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 container to run autonomous agents with full tool access.'
|
||||
: 'Podman is required to run OpenClaw agents. Install Podman first.'}
|
||||
? 'Create a local BrowserOS VM to run autonomous agents with full tool access.'
|
||||
: 'BrowserOS VM runtime is unavailable on this system.'}
|
||||
</p>
|
||||
</div>
|
||||
{status.podmanAvailable && (
|
||||
@@ -770,7 +659,7 @@ export const AgentsPage: FC = () => {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={!gatewayUiState.canManageAgents}
|
||||
disabled={!canManageAgents}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
Create Agent
|
||||
@@ -788,20 +677,10 @@ 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">
|
||||
@@ -809,7 +688,7 @@ export const AgentsPage: FC = () => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setChatAgent(agent)}
|
||||
disabled={!gatewayUiState.canManageAgents}
|
||||
disabled={!canManageAgents}
|
||||
>
|
||||
<MessageSquare className="mr-1 size-4" />
|
||||
Chat
|
||||
@@ -819,7 +698,7 @@ export const AgentsPage: FC = () => {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(agent.agentId)}
|
||||
disabled={!gatewayUiState.canManageAgents || deleting}
|
||||
disabled={!canManageAgents || deleting}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
@@ -868,246 +747,6 @@ 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"
|
||||
@@ -1141,10 +780,8 @@ export const AgentsPage: FC = () => {
|
||||
disabled={
|
||||
!newName.trim() ||
|
||||
creating ||
|
||||
rolesLoading ||
|
||||
!gatewayUiState.canManageAgents ||
|
||||
compatibleProviders.length === 0 ||
|
||||
(creationMode === 'builtin' && !selectedRole)
|
||||
!canManageAgents ||
|
||||
compatibleProviders.length === 0
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
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'
|
||||
@@ -11,27 +7,6 @@ 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 {
|
||||
@@ -61,8 +36,6 @@ export interface OpenClawStatus {
|
||||
|
||||
export interface OpenClawAgentMutationInput {
|
||||
name: string
|
||||
roleId?: BrowserOSAgentRoleId
|
||||
customRole?: BrowserOSCustomRoleInput
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
@@ -86,9 +59,15 @@ 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,
|
||||
@@ -117,16 +96,6 @@ 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> {
|
||||
@@ -179,28 +148,6 @@ 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()
|
||||
@@ -278,6 +225,13 @@ 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,
|
||||
@@ -298,6 +252,7 @@ export function useOpenClawMutations() {
|
||||
creating: createMutation.isPending,
|
||||
deleting: deleteMutation.isPending,
|
||||
reconnecting: reconnectMutation.isPending,
|
||||
pendingGatewayAction,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,17 +269,60 @@ 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 }),
|
||||
body: JSON.stringify({ message, sessionKey, history }),
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ import { PRODUCT_WEB_HOST } from './lib/constants/productWebHost'
|
||||
// biome-ignore lint/style/noProcessEnv: build config file needs env access
|
||||
const env = process.env
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: required env var
|
||||
const apiUrl = new URL(env.VITE_PUBLIC_BROWSEROS_API!)
|
||||
const apiUrl = new URL(
|
||||
env.VITE_PUBLIC_BROWSEROS_API?.trim() || 'https://api.browseros.com',
|
||||
)
|
||||
const apiPattern = apiUrl.port
|
||||
? `${apiUrl.hostname}:${apiUrl.port}`
|
||||
: apiUrl.hostname
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@browseros/server",
|
||||
"version": "0.0.85",
|
||||
"version": "0.0.88",
|
||||
"description": "BrowserOS server",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
@@ -10,9 +10,21 @@
|
||||
"scripts": {
|
||||
"start": "bun --watch --env-file=.env.development src/index.ts",
|
||||
"build": "bun ../../scripts/build/server.ts --target=all",
|
||||
"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": "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:cleanup": "./tests/__helpers__/cleanup.sh",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"devtools": "bunx @ai-sdk/devtools"
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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
|
||||
@@ -10,6 +10,7 @@ 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'
|
||||
@@ -39,16 +40,35 @@ 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,
|
||||
@@ -62,6 +82,9 @@ 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', {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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 })
|
||||
})
|
||||
}
|
||||
@@ -7,38 +7,26 @@
|
||||
* 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 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)
|
||||
)
|
||||
function getCreateAgentValidationError(body: { name?: string }): string | null {
|
||||
if (!body.name?.trim()) {
|
||||
return 'Name is required'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function createOpenClawRoutes() {
|
||||
@@ -72,7 +60,7 @@ export function createOpenClawRoutes() {
|
||||
return c.json(
|
||||
{
|
||||
status: 'running',
|
||||
port: OPENCLAW_GATEWAY_PORT,
|
||||
port: getOpenClawService().getPort(),
|
||||
agents: agents.map((a) => ({
|
||||
agentId: a.agentId,
|
||||
name: a.name,
|
||||
@@ -89,7 +77,10 @@ export function createOpenClawRoutes() {
|
||||
providerType: body.providerType,
|
||||
providerName: body.providerName,
|
||||
})
|
||||
if (message.includes('Podman is not available')) {
|
||||
if (isUnsupportedOpenClawProviderError(err)) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
if (message.includes('VM runtime is not available')) {
|
||||
return c.json({ error: message }, 503)
|
||||
}
|
||||
return c.json({ error: message }, 500)
|
||||
@@ -154,97 +145,23 @@ 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 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,
|
||||
)
|
||||
const validationError = getCreateAgentValidationError(body)
|
||||
if (validationError) {
|
||||
return c.json({ error: validationError }, 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const agent = await getOpenClawService().createAgent({
|
||||
name,
|
||||
roleId: body.roleId,
|
||||
customRole: body.customRole,
|
||||
name: body.name.trim(),
|
||||
providerType: body.providerType,
|
||||
providerName: body.providerName,
|
||||
baseUrl: body.baseUrl,
|
||||
@@ -259,6 +176,9 @@ 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)
|
||||
}
|
||||
@@ -287,6 +207,7 @@ export function createOpenClawRoutes() {
|
||||
const body = await c.req.json<{
|
||||
message: string
|
||||
sessionKey?: string
|
||||
history?: MonitoringChatTurn[]
|
||||
}>()
|
||||
|
||||
if (!body.message?.trim()) {
|
||||
@@ -294,12 +215,37 @@ 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')
|
||||
@@ -309,20 +255,123 @@ 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)
|
||||
}
|
||||
@@ -352,12 +401,17 @@ export function createOpenClawRoutes() {
|
||||
}
|
||||
|
||||
try {
|
||||
await getOpenClawService().updateProviderKeys(body)
|
||||
const result = await getOpenClawService().updateProviderKeys(body)
|
||||
return c.json({
|
||||
status: 'restarting',
|
||||
message: 'Provider updated, restarting gateway',
|
||||
status: result.restarted ? 'restarting' : 'updated',
|
||||
message: result.restarted
|
||||
? 'Provider updated, restarting gateway'
|
||||
: 'Provider updated without a restart',
|
||||
})
|
||||
} 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)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ export const TERMINAL_WS_PATH = '/terminal/ws'
|
||||
|
||||
interface TerminalRouteDeps {
|
||||
containerName: string
|
||||
podmanPath: string
|
||||
limaHome: string
|
||||
limactlPath: string
|
||||
vmName: string
|
||||
}
|
||||
|
||||
function safeSend(ws: { send(data: string): void }, data: string): void {
|
||||
@@ -45,7 +47,9 @@ function createSocketEvents(deps: TerminalRouteDeps) {
|
||||
try {
|
||||
session = createTerminalSession({
|
||||
containerName: deps.containerName,
|
||||
podmanPath: deps.podmanPath,
|
||||
limaHome: deps.limaHome,
|
||||
limactlPath: deps.limactlPath,
|
||||
vmName: deps.vmName,
|
||||
workingDir: TERMINAL_HOME_DIR,
|
||||
onOutput(data) {
|
||||
sendOutput(ws, data)
|
||||
|
||||
@@ -22,6 +22,7 @@ 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'
|
||||
@@ -29,6 +30,7 @@ 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'
|
||||
@@ -44,7 +46,6 @@ 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'
|
||||
@@ -113,7 +114,9 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
'/',
|
||||
createTerminalRoutes({
|
||||
containerName: OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
podmanPath: getPodmanRuntime().getPodmanPath(),
|
||||
limaHome: getLimaHomeDir(),
|
||||
limactlPath: resolveBundledLimactl(resourcesDir),
|
||||
vmName: VM_NAME,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -121,6 +124,10 @@ 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 }))
|
||||
@@ -143,6 +150,7 @@ 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 }))
|
||||
|
||||
@@ -20,6 +20,7 @@ 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> {
|
||||
@@ -237,6 +238,7 @@ export function buildKlavisToolSet(handle: KlavisProxyHandle): ToolSet {
|
||||
export function registerKlavisTools(
|
||||
mcpServer: McpServer,
|
||||
handle: KlavisProxyHandle,
|
||||
observer?: ToolExecutionObserver,
|
||||
): void {
|
||||
mcpServer.registerTool(
|
||||
'connector_mcp_servers',
|
||||
@@ -247,9 +249,16 @@ 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,
|
||||
@@ -266,6 +275,14 @@ export function registerKlavisTools(
|
||||
success: true,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
output: {
|
||||
connected: true,
|
||||
server_name,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -294,6 +311,15 @@ export function registerKlavisTools(
|
||||
success: true,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
output: {
|
||||
connected: false,
|
||||
server_name,
|
||||
authUrl,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -320,6 +346,11 @@ export function registerKlavisTools(
|
||||
error_message: errorText,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
error: errorText,
|
||||
})
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: errorText }],
|
||||
isError: true,
|
||||
@@ -339,7 +370,14 @@ 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', {
|
||||
@@ -349,6 +387,12 @@ 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 =
|
||||
@@ -362,6 +406,11 @@ export function registerKlavisTools(
|
||||
error_message: errorText,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
error: errorText,
|
||||
})
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: errorText }],
|
||||
isError: true,
|
||||
|
||||
@@ -8,6 +8,7 @@ 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,
|
||||
@@ -24,6 +25,7 @@ export interface McpServiceDeps {
|
||||
resourcesDir: string
|
||||
aclRules?: AclRule[]
|
||||
klavisRef?: KlavisProxyRef
|
||||
observer?: ToolExecutionObserver
|
||||
}
|
||||
|
||||
export function createMcpServer(deps: McpServiceDeps): McpServer {
|
||||
@@ -48,11 +50,12 @@ 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)
|
||||
registerKlavisTools(server, deps.klavisRef.handle, deps.observer)
|
||||
}
|
||||
|
||||
return server
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
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,
|
||||
ctx: ToolContext & { observer?: ToolExecutionObserver },
|
||||
): void {
|
||||
for (const tool of registry.all()) {
|
||||
const handler = async (
|
||||
@@ -15,9 +16,16 @@ 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)
|
||||
|
||||
@@ -28,6 +36,12 @@ 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,
|
||||
@@ -44,6 +58,11 @@ export function registerTools(
|
||||
source: 'mcp',
|
||||
})
|
||||
|
||||
await ctx.observer?.onToolEnd({
|
||||
toolCallId,
|
||||
error: errorText,
|
||||
})
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: errorText }],
|
||||
isError: true,
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* @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)
|
||||
}
|
||||
@@ -2,191 +2,290 @@
|
||||
* @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 } from '../../../lib/container'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import type { LogFn, PodmanRuntime } from './podman-runtime'
|
||||
import {
|
||||
GUEST_VM_STATE,
|
||||
hostPathToGuest,
|
||||
type LogFn,
|
||||
type VmRuntime,
|
||||
} from '../../../lib/vm'
|
||||
|
||||
const COMPOSE_FILE_NAME = 'docker-compose.yml'
|
||||
const ENV_FILE_NAME = '.env'
|
||||
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
|
||||
}
|
||||
|
||||
export class ContainerRuntime {
|
||||
constructor(
|
||||
private podman: PodmanRuntime,
|
||||
private projectDir: string,
|
||||
) {}
|
||||
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
|
||||
}
|
||||
|
||||
async ensureReady(onLog?: LogFn): Promise<void> {
|
||||
logger.info('Ensuring Podman runtime readiness')
|
||||
return this.podman.ensureReady(onLog)
|
||||
logger.info('Ensuring BrowserOS VM runtime readiness')
|
||||
await this.vm.ensureReady(onLog)
|
||||
await this.vm.getDefaultGateway()
|
||||
}
|
||||
|
||||
async isPodmanAvailable(): Promise<boolean> {
|
||||
return this.podman.isPodmanAvailable()
|
||||
return true
|
||||
}
|
||||
|
||||
async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
return this.podman.getMachineStatus()
|
||||
const running = await this.vm.isReady()
|
||||
return { initialized: running, running }
|
||||
}
|
||||
|
||||
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 pullImage(image: string, onLog?: LogFn): Promise<void> {
|
||||
await this.loader.ensureImageLoaded(image, onLog)
|
||||
}
|
||||
|
||||
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 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 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 stopGateway(onLog?: LogFn): Promise<void> {
|
||||
await this.removeGatewayContainer(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 restartGateway(
|
||||
input: GatewayContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
await this.startGateway(input, onLog)
|
||||
}
|
||||
|
||||
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[]> {
|
||||
async getGatewayLogs(tail = 50): Promise<string[]> {
|
||||
const lines: string[] = []
|
||||
await this.compose(['logs', '--no-color', '--tail', String(tail)], (line) =>
|
||||
lines.push(line),
|
||||
await this.vm.runCommand(
|
||||
['nerdctl', 'logs', '-n', String(tail), OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
{ onOutput: (line) => lines.push(line) },
|
||||
)
|
||||
return lines
|
||||
}
|
||||
|
||||
async isHealthy(port: number): Promise<boolean> {
|
||||
async isHealthy(hostPort: number): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/healthz`)
|
||||
const res = await fetch(`http://127.0.0.1:${hostPort}/healthz`)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async isReady(port: number): Promise<boolean> {
|
||||
async isReady(hostPort: number): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/readyz`)
|
||||
const res = await fetch(`http://127.0.0.1:${hostPort}/readyz`)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async waitForReady(port: number, timeoutMs = 30_000): Promise<boolean> {
|
||||
logger.info('Waiting for OpenClaw gateway readiness', { port, timeoutMs })
|
||||
async waitForReady(hostPort: number, timeoutMs = 30_000): Promise<boolean> {
|
||||
logger.info('Waiting for OpenClaw gateway readiness', {
|
||||
hostPort,
|
||||
timeoutMs,
|
||||
})
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await this.isReady(port)) {
|
||||
logger.info('OpenClaw gateway became ready', {
|
||||
port,
|
||||
waitMs: Date.now() - start,
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (await this.isReady(hostPort)) return true
|
||||
await Bun.sleep(1000)
|
||||
}
|
||||
logger.error('Timed out waiting for OpenClaw gateway readiness', {
|
||||
port,
|
||||
hostPort,
|
||||
timeoutMs,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
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 stopVm(): Promise<void> {
|
||||
await this.vm.stopVm()
|
||||
}
|
||||
|
||||
async execInContainer(command: string[], onLog?: LogFn): Promise<number> {
|
||||
return this.podman.runCommand(
|
||||
['exec', OPENCLAW_GATEWAY_CONTAINER_NAME, ...command],
|
||||
{
|
||||
onOutput: onLog,
|
||||
},
|
||||
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.vm.runCommand(['nerdctl', 'rm', '-f', setupContainerName], {
|
||||
onOutput: onLog,
|
||||
})
|
||||
await this.loader.ensureImageLoaded(spec.image, onLog)
|
||||
const setupArgs = command[0] === 'node' ? command.slice(1) : command
|
||||
const createExitCode = await this.vm.runCommand(
|
||||
[
|
||||
'nerdctl',
|
||||
'create',
|
||||
'--name',
|
||||
setupContainerName,
|
||||
...(await this.buildGatewayRunArgs(spec)),
|
||||
spec.image,
|
||||
'node',
|
||||
...setupArgs,
|
||||
],
|
||||
{ onOutput: onLog },
|
||||
)
|
||||
if (createExitCode !== 0) {
|
||||
await this.vm.runCommand(['nerdctl', 'rm', '-f', setupContainerName], {
|
||||
onOutput: onLog,
|
||||
})
|
||||
return createExitCode
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.vm.runCommand(
|
||||
['nerdctl', 'start', '-a', setupContainerName],
|
||||
{ onOutput: onLog },
|
||||
)
|
||||
} finally {
|
||||
await this.vm.runCommand(['nerdctl', 'rm', '-f', setupContainerName], {
|
||||
onOutput: onLog,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
tailGatewayLogs(onLine: LogFn): () => void {
|
||||
return this.podman.tailContainerLogs(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
onLine,
|
||||
)
|
||||
return this.shell.tailLogs(OPENCLAW_GATEWAY_CONTAINER_NAME, onLine)
|
||||
}
|
||||
|
||||
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)
|
||||
},
|
||||
})
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error('OpenClaw compose command failed', {
|
||||
command,
|
||||
exitCode: code,
|
||||
output: lines,
|
||||
})
|
||||
} else {
|
||||
logger.info('OpenClaw compose command succeeded', {
|
||||
command,
|
||||
})
|
||||
private async removeGatewayContainer(onLog?: LogFn): Promise<void> {
|
||||
if (onLog) {
|
||||
await this.shell.removeContainer(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
{ force: true },
|
||||
onLog,
|
||||
)
|
||||
return
|
||||
}
|
||||
await this.shell.removeContainer(OPENCLAW_GATEWAY_CONTAINER_NAME, {
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
|
||||
return code
|
||||
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,
|
||||
},
|
||||
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}`)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,3 +27,10 @@ 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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,754 +0,0 @@
|
||||
/**
|
||||
* @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) ?? '' },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* @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')
|
||||
)
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @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` : ''
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* @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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @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 }],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @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>
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
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}
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
@@ -11,7 +11,9 @@ const TERMINAL_NAME = 'xterm-256color'
|
||||
|
||||
interface TerminalSessionDeps {
|
||||
containerName: string
|
||||
podmanPath: string
|
||||
limaHome: string
|
||||
limactlPath: string
|
||||
vmName: string
|
||||
workingDir: string
|
||||
onExit: (exitCode: number) => void
|
||||
onOutput: (data: string) => void
|
||||
@@ -24,12 +26,17 @@ export interface TerminalSession {
|
||||
}
|
||||
|
||||
export function buildTerminalExecCommand(
|
||||
podmanPath: string,
|
||||
limactlPath: string,
|
||||
vmName: string,
|
||||
containerName: string,
|
||||
workingDir: string,
|
||||
): string[] {
|
||||
return [
|
||||
podmanPath,
|
||||
limactlPath,
|
||||
'shell',
|
||||
vmName,
|
||||
'--',
|
||||
'nerdctl',
|
||||
'exec',
|
||||
'-it',
|
||||
'-w',
|
||||
@@ -39,17 +46,23 @@ export function buildTerminalExecCommand(
|
||||
]
|
||||
}
|
||||
|
||||
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.podmanPath,
|
||||
deps.limactlPath,
|
||||
deps.vmName,
|
||||
deps.containerName,
|
||||
deps.workingDir,
|
||||
),
|
||||
{
|
||||
cwd: '/',
|
||||
terminal: {
|
||||
cols: DEFAULT_COLS,
|
||||
rows: DEFAULT_ROWS,
|
||||
@@ -58,7 +71,7 @@ export function createTerminalSession(
|
||||
if (chunk) deps.onOutput(chunk)
|
||||
},
|
||||
},
|
||||
env: { ...process.env, TERM: TERMINAL_NAME },
|
||||
env: buildTerminalEnv(deps.limaHome),
|
||||
},
|
||||
)
|
||||
let closed = false
|
||||
|
||||
@@ -517,15 +517,45 @@ 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 }),
|
||||
...(opts?.windowId !== undefined && { windowId: opts.windowId }),
|
||||
...(windowId !== undefined && { windowId }),
|
||||
})
|
||||
|
||||
const tabId = (createResult.tab as TabInfo).tabId
|
||||
@@ -553,7 +583,7 @@ export class Browser {
|
||||
loadProgress: tabInfo.loadProgress,
|
||||
isPinned: tabInfo.isPinned,
|
||||
isHidden: tabInfo.isHidden,
|
||||
windowId: tabInfo.windowId,
|
||||
windowId: tabInfo.windowId ?? windowId,
|
||||
index: tabInfo.index,
|
||||
groupId: tabInfo.groupId,
|
||||
})
|
||||
|
||||
@@ -7,7 +7,16 @@ import type { ServerDiscoveryConfig } from '@browseros/shared/types/server-confi
|
||||
import { logger } from './logger'
|
||||
|
||||
export function getBrowserosDir(): string {
|
||||
return join(homedir(), PATHS.BROWSEROS_DIR_NAME)
|
||||
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()}`)
|
||||
}
|
||||
|
||||
export function getMemoryDir(): string {
|
||||
@@ -35,9 +44,49 @@ 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)
|
||||
}
|
||||
@@ -57,10 +106,13 @@ 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> {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @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 interface ContainerCliConfig {
|
||||
limactlPath: string
|
||||
limaHome: string
|
||||
vmName: string
|
||||
sshPath?: string
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
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.run(['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.run(['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.run(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.run(['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,
|
||||
['nerdctl', 'logs', '-f', '-n', '0', name],
|
||||
{ onStdout: onLine, onStderr: onLine },
|
||||
)
|
||||
|
||||
let stopped = false
|
||||
return () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
proc.kill()
|
||||
}
|
||||
}
|
||||
|
||||
private async runRequired(
|
||||
args: string[],
|
||||
onLog?: LogFn,
|
||||
): Promise<CommandResult> {
|
||||
const result = await this.run(args, onLog)
|
||||
if (result.exitCode === 0) return result
|
||||
throw this.commandError(args, result)
|
||||
}
|
||||
|
||||
private async run(args: string[], onLog?: LogFn): Promise<CommandResult> {
|
||||
const stdoutLines: string[] = []
|
||||
const stderrLines: string[] = []
|
||||
const exitCode = await this.lima.shell(
|
||||
this.cfg.vmName,
|
||||
['nerdctl', ...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 commandError(
|
||||
args: string[],
|
||||
result: CommandResult,
|
||||
): 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`
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @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}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export * from './container-cli'
|
||||
export * from './image-loader'
|
||||
export * from './types'
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
54
packages/browseros-agent/apps/server/src/lib/vm/errors.ts
Normal file
54
packages/browseros-agent/apps/server/src/lib/vm/errors.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @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(
|
||||
`VM manifest is missing at ${manifestPath}; run bun run cache:sync before starting the server`,
|
||||
)
|
||||
}
|
||||
}
|
||||
13
packages/browseros-agent/apps/server/src/lib/vm/index.ts
Normal file
13
packages/browseros-agent/apps/server/src/lib/vm/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @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'
|
||||
265
packages/browseros-agent/apps/server/src/lib/vm/lima-cli.ts
Normal file
265
packages/browseros-agent/apps/server/src/lib/vm/lima-cli.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* @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>
|
||||
}
|
||||
|
||||
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 [stdout, stderr, exitCode] = await Promise.all([
|
||||
drainToString(proc.stdout),
|
||||
drainToString(proc.stderr, (line) => {
|
||||
logger.debug(VM_TELEMETRY_EVENTS.limaStderrChunk, {
|
||||
pid: proc.pid,
|
||||
firstArg: args[0],
|
||||
line,
|
||||
})
|
||||
}),
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Arch } from './paths'
|
||||
|
||||
export interface LimaConfigInput {
|
||||
arch: Arch
|
||||
diskPath: string
|
||||
cpus: number
|
||||
memory: string
|
||||
disk: string
|
||||
vmStateDir: string
|
||||
imageCacheDir: string
|
||||
socketHostPath: string
|
||||
}
|
||||
|
||||
export function generateLimaYaml(cfg: LimaConfigInput): string {
|
||||
const arch = cfg.arch === 'arm64' ? 'aarch64' : 'x86_64'
|
||||
return [
|
||||
'vmType: "vz"',
|
||||
'rosetta:',
|
||||
' enabled: false',
|
||||
`arch: "${arch}"`,
|
||||
`cpus: ${cfg.cpus}`,
|
||||
`memory: "${cfg.memory}"`,
|
||||
`disk: "${cfg.disk}"`,
|
||||
'images:',
|
||||
` - location: "${cfg.diskPath}"`,
|
||||
` arch: "${arch}"`,
|
||||
'mounts:',
|
||||
` - location: "${cfg.vmStateDir}"`,
|
||||
' mountPoint: "/mnt/browseros/vm"',
|
||||
' writable: true',
|
||||
` - location: "${cfg.imageCacheDir}"`,
|
||||
' mountPoint: "/mnt/browseros/cache/images"',
|
||||
' writable: false',
|
||||
'portForwards:',
|
||||
' - guestSocket: "/var/run/containerd/containerd.sock"',
|
||||
` hostSocket: "${cfg.socketHostPath}"`,
|
||||
'user:',
|
||||
' name: "browseros"',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
102
packages/browseros-agent/apps/server/src/lib/vm/manifest.ts
Normal file
102
packages/browseros-agent/apps/server/src/lib/vm/manifest.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
177
packages/browseros-agent/apps/server/src/lib/vm/paths.ts
Normal file
177
packages/browseros-agent/apps/server/src/lib/vm/paths.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { homedir, arch as osArch } from 'node:os'
|
||||
import { 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'
|
||||
|
||||
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 'limactl'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 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('/')}`
|
||||
}
|
||||
35
packages/browseros-agent/apps/server/src/lib/vm/telemetry.ts
Normal file
35
packages/browseros-agent/apps/server/src/lib/vm/telemetry.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @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',
|
||||
upgradeSwap: 'vm.upgrade.swap',
|
||||
upgradeReplay: 'vm.upgrade.replay',
|
||||
resetDetected: 'vm.reset.detected',
|
||||
resetOk: 'vm.reset.ok',
|
||||
socketWaitStart: 'vm.socket_wait.start',
|
||||
socketWaitOk: 'vm.socket_wait.ok',
|
||||
socketWaitPoll: 'vm.socket_wait.poll',
|
||||
socketWaitTimeout: 'vm.socket_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
|
||||
351
packages/browseros-agent/apps/server/src/lib/vm/vm-runtime.ts
Normal file
351
packages/browseros-agent/apps/server/src/lib/vm/vm-runtime.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, stat, 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 {
|
||||
getContainerdSocketPath,
|
||||
getImageCacheDir,
|
||||
getVmStateDir,
|
||||
VM_NAME,
|
||||
} from './paths'
|
||||
import { VM_TELEMETRY_EVENTS } from './telemetry'
|
||||
|
||||
export type LogFn = (msg: string) => void
|
||||
|
||||
export interface VmRuntimeDeps {
|
||||
limactlPath: string
|
||||
limaHome: string
|
||||
sshPath?: string
|
||||
templatePath?: string
|
||||
browserosRoot?: string
|
||||
socketTimeoutMs?: number
|
||||
socketPollMs?: number
|
||||
}
|
||||
|
||||
export class VmRuntime {
|
||||
private readonly cli: LimaCli
|
||||
private readonly socketTimeoutMs: number
|
||||
private readonly socketPollMs: 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.socketTimeoutMs = deps.socketTimeoutMs ?? 60_000
|
||||
this.socketPollMs = deps.socketPollMs ?? 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'
|
||||
: '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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await this.waitForSocket(this.socketTimeoutMs)
|
||||
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 listRunningContainers(): Promise<string[]> {
|
||||
const lines: string[] = []
|
||||
await this.runCommand(['nerdctl', 'ps', '--format', '{{.Names}}'], {
|
||||
onOutput: (line) => lines.push(line),
|
||||
})
|
||||
return lines.map((line) => line.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
tailContainerLogs(containerName: string, onLine: LogFn): () => void {
|
||||
const proc = this.cli.spawnShell(
|
||||
VM_NAME,
|
||||
['nerdctl', 'logs', '-f', '-n', '0', containerName],
|
||||
{ onStdout: onLine, onStderr: onLine },
|
||||
)
|
||||
|
||||
let stopped = false
|
||||
return () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
proc.kill()
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
try {
|
||||
const info = await stat(this.socketPath())
|
||||
return info.isSocket()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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() === 'runtime:containerd')
|
||||
}
|
||||
|
||||
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 waitForSocket(timeoutMs: number): Promise<void> {
|
||||
const started = Date.now()
|
||||
const deadline = started + timeoutMs
|
||||
const sockPath = this.socketPath()
|
||||
logger.info(VM_TELEMETRY_EVENTS.socketWaitStart, {
|
||||
sockPath,
|
||||
timeoutMs,
|
||||
pollMs: this.socketPollMs,
|
||||
})
|
||||
let pollCount = 0
|
||||
while (Date.now() < deadline) {
|
||||
pollCount += 1
|
||||
if (await this.isReady()) {
|
||||
logger.info(VM_TELEMETRY_EVENTS.socketWaitOk, {
|
||||
sockPath,
|
||||
pollCount,
|
||||
waitMs: Date.now() - started,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (pollCount === 1 || pollCount % 10 === 0) {
|
||||
logger.debug(VM_TELEMETRY_EVENTS.socketWaitPoll, {
|
||||
sockPath,
|
||||
pollCount,
|
||||
elapsedMs: Date.now() - started,
|
||||
})
|
||||
}
|
||||
await Bun.sleep(this.socketPollMs)
|
||||
}
|
||||
logger.error(VM_TELEMETRY_EVENTS.socketWaitTimeout, {
|
||||
sockPath,
|
||||
timeoutMs,
|
||||
pollCount,
|
||||
})
|
||||
throw new VmNotReadyError(`containerd.sock never appeared at ${sockPath}`)
|
||||
}
|
||||
|
||||
private socketPath(): string {
|
||||
return getContainerdSocketPath(this.deps.browserosRoot)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -13,8 +13,11 @@ 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 { getOpenClawService } from './api/services/openclaw/openclaw-service'
|
||||
import { configurePodmanRuntime } from './api/services/openclaw/podman-runtime'
|
||||
import {
|
||||
configureOpenClawService,
|
||||
configureVmRuntime,
|
||||
getOpenClawService,
|
||||
} from './api/services/openclaw/openclaw-service'
|
||||
import { CdpBackend } from './browser/backends/cdp'
|
||||
import { Browser } from './browser/browser'
|
||||
import type { ServerConfig } from './config'
|
||||
@@ -56,9 +59,8 @@ export class Application {
|
||||
resourcesDir: path.resolve(this.config.resourcesDir),
|
||||
})
|
||||
|
||||
configurePodmanRuntime({
|
||||
resourcesDir: path.resolve(this.config.resourcesDir),
|
||||
})
|
||||
const resourcesDir = path.resolve(this.config.resourcesDir)
|
||||
configureVmRuntime({ resourcesDir })
|
||||
await this.initCoreServices()
|
||||
|
||||
if (!this.config.cdpPort) {
|
||||
@@ -123,7 +125,10 @@ export class Application {
|
||||
this.logStartupSummary()
|
||||
startSkillSync()
|
||||
|
||||
getOpenClawService(this.config.serverPort)
|
||||
configureOpenClawService({
|
||||
browserosServerPort: this.config.serverPort,
|
||||
resourcesDir,
|
||||
})
|
||||
.tryAutoStart()
|
||||
.catch((err) =>
|
||||
logger.warn('OpenClaw auto-start failed', {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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),
|
||||
})
|
||||
}
|
||||
222
packages/browseros-agent/apps/server/src/monitoring/service.ts
Normal file
222
packages/browseros-agent/apps/server/src/monitoring/service.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
175
packages/browseros-agent/apps/server/src/monitoring/storage.ts
Normal file
175
packages/browseros-agent/apps/server/src/monitoring/storage.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
92
packages/browseros-agent/apps/server/src/monitoring/types.ts
Normal file
92
packages/browseros-agent/apps/server/src/monitoring/types.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
process.env.NODE_ENV = 'test'
|
||||
@@ -1168,8 +1168,9 @@ describe('compaction E2E — pruning and output reduction', () => {
|
||||
{ role: 'user', content: 'x'.repeat(3000) },
|
||||
]
|
||||
const estimated = estimateTokensForThreshold(messages, config)
|
||||
// 3000 chars / 3 = 1000 tokens, * 1.3 = 1300, + 12000 = 13300
|
||||
expect(estimated).toBe(Math.ceil(1000 * 1.3) + 12_000)
|
||||
expect(estimated).toBe(
|
||||
Math.ceil(1000 * config.safetyMultiplier) + config.fixedOverhead,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('createKlavisRoutes', () => {
|
||||
it('normalizes string integrations into authenticated entries', async () => {
|
||||
it('normalizes string integrations into unauthenticated 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: true },
|
||||
{ name: 'Slack', is_authenticated: true },
|
||||
{ name: 'Google Docs', is_authenticated: false },
|
||||
{ name: 'Slack', is_authenticated: false },
|
||||
],
|
||||
count: 2,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* @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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -4,11 +4,13 @@
|
||||
*/
|
||||
|
||||
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'
|
||||
@@ -49,21 +51,35 @@ describe('terminal protocol', () => {
|
||||
).toBe('{"type":"output","data":"hello"}')
|
||||
})
|
||||
|
||||
it('builds a podman exec command rooted in the container home dir', () => {
|
||||
it('builds a limactl shell command rooted in the container home dir', () => {
|
||||
expect(
|
||||
buildTerminalExecCommand(
|
||||
'podman',
|
||||
'browseros-openclaw-openclaw-gateway-1',
|
||||
'limactl',
|
||||
'browseros-vm',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
TERMINAL_HOME_DIR,
|
||||
),
|
||||
).toEqual([
|
||||
'podman',
|
||||
'limactl',
|
||||
'shell',
|
||||
'browseros-vm',
|
||||
'--',
|
||||
'nerdctl',
|
||||
'exec',
|
||||
'-it',
|
||||
'-w',
|
||||
'/home/node/.openclaw',
|
||||
'browseros-openclaw-openclaw-gateway-1',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
'/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',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @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 },
|
||||
)
|
||||
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.vm.runCommand).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
'nerdctl',
|
||||
'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',
|
||||
]),
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(deps.vm.runCommand).toHaveBeenCalledWith(
|
||||
['nerdctl', 'start', '-a', `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`],
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(deps.vm.runCommand).toHaveBeenCalledWith(
|
||||
['nerdctl', 'rm', '-f', `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`],
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
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.vm.runCommand).toHaveBeenCalledWith(
|
||||
['nerdctl', 'logs', '-n', '10', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
expect.any(Object),
|
||||
)
|
||||
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),
|
||||
runCommand: mock(
|
||||
async (
|
||||
_args: string[],
|
||||
opts?: { onOutput?: (line: string) => void },
|
||||
) => {
|
||||
opts?.onOutput?.('log line')
|
||||
return 0
|
||||
},
|
||||
),
|
||||
},
|
||||
shell: {
|
||||
createContainer: mock(async () => {}),
|
||||
startContainer: mock(async () => {}),
|
||||
stopContainer: mock(async () => {}),
|
||||
removeContainer: mock(async () => {}),
|
||||
exec: mock(async () => 0),
|
||||
tailLogs: mock(() => () => {}),
|
||||
},
|
||||
loader: {
|
||||
ensureImageLoaded: mock(async () => {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* @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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* @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('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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* @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')
|
||||
})
|
||||
})
|
||||
108
packages/browseros-agent/apps/server/tests/browseros-dir.test.ts
Normal file
108
packages/browseros-agent/apps/server/tests/browseros-dir.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @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('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,
|
||||
socketTimeoutMs: 5 * 60 * 1000,
|
||||
socketPollMs: 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 nerdctlInfoExit = await runtime.runCommand(['nerdctl', 'info'], {
|
||||
onOutput: (line) => nerdctlInfoOutput.push(line),
|
||||
})
|
||||
if (nerdctlInfoExit !== 0) {
|
||||
throw new Error(
|
||||
`nerdctl info failed with exit ${nerdctlInfoExit}:\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,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* @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`
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @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']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @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.socketWaitTimeout).toBe('vm.socket_wait.timeout')
|
||||
expect(VM_TELEMETRY_EVENTS.migrationOpenClawMoved).toBe(
|
||||
'vm.migration.openclaw_moved',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @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 { LimaCommandError, VmNotReadyError } from '../../../src/lib/vm/errors'
|
||||
import { LimaCli } from '../../../src/lib/vm/lima-cli'
|
||||
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('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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
generateLimaYaml,
|
||||
renderLimaTemplate,
|
||||
} from '../../../src/lib/vm/lima-config'
|
||||
|
||||
describe('generateLimaYaml', () => {
|
||||
it('generates the BrowserOS Lima VM config for arm64', () => {
|
||||
const yaml = generateLimaYaml({
|
||||
arch: 'arm64',
|
||||
diskPath: '/cache/browseros-vm.qcow2',
|
||||
cpus: 2,
|
||||
memory: '2GiB',
|
||||
disk: '10GiB',
|
||||
vmStateDir: '/Users/me/.browseros/vm',
|
||||
imageCacheDir: '/Users/me/.browseros/cache/vm/images',
|
||||
socketHostPath:
|
||||
'/Users/me/.browseros/lima/browseros-vm/sock/containerd.sock',
|
||||
})
|
||||
|
||||
expect(yaml).toContain('vmType: "vz"')
|
||||
expect(yaml).toContain('arch: "aarch64"')
|
||||
expect(yaml).toContain('location: "/cache/browseros-vm.qcow2"')
|
||||
expect(yaml).toContain('mountPoint: "/mnt/browseros/vm"')
|
||||
expect(yaml).toContain('writable: true')
|
||||
expect(yaml).toContain('mountPoint: "/mnt/browseros/cache/images"')
|
||||
expect(yaml).toContain('writable: false')
|
||||
expect(yaml).toContain('guestSocket: "/var/run/containerd/containerd.sock"')
|
||||
expect(yaml).toContain(
|
||||
'hostSocket: "/Users/me/.browseros/lima/browseros-vm/sock/containerd.sock"',
|
||||
)
|
||||
expect(yaml).toContain('name: "browseros"')
|
||||
expect(yaml).not.toContain('mountType: "9p"')
|
||||
})
|
||||
|
||||
it('maps x64 to Lima x86_64', () => {
|
||||
const yaml = generateLimaYaml({
|
||||
arch: 'x64',
|
||||
diskPath: '/cache/browseros-vm.qcow2',
|
||||
cpus: 4,
|
||||
memory: '4GiB',
|
||||
disk: '20GiB',
|
||||
vmStateDir: '/Users/me/.browseros/vm',
|
||||
imageCacheDir: '/Users/me/.browseros/cache/vm/images',
|
||||
socketHostPath:
|
||||
'/Users/me/.browseros/lima/browseros-vm/sock/containerd.sock',
|
||||
})
|
||||
|
||||
expect(yaml).toContain('arch: "x86_64"')
|
||||
expect(yaml).toContain('cpus: 4')
|
||||
expect(yaml).toContain('memory: "4GiB"')
|
||||
expect(yaml).toContain('disk: "20GiB"')
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @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 with a cache-sync hint when cached manifest is absent', async () => {
|
||||
await expect(readCachedManifest(root)).rejects.toThrow(ManifestMissingError)
|
||||
await expect(readCachedManifest(root)).rejects.toThrow('bun run cache:sync')
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
187
packages/browseros-agent/apps/server/tests/lib/vm/paths.test.ts
Normal file
187
packages/browseros-agent/apps/server/tests/lib/vm/paths.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { 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
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV
|
||||
} else {
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
}
|
||||
})
|
||||
|
||||
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', () => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
|
||||
expect(resolveBundledLimactl('/tmp/missing-dev-resources')).toBe('limactl')
|
||||
})
|
||||
|
||||
it('uses PATH limactl in test mode', () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
|
||||
expect(resolveBundledLimactl('/tmp/missing-test-resources')).toBe('limactl')
|
||||
})
|
||||
|
||||
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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* @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 { logger } from '../../../src/lib/logger'
|
||||
import { VmNotReadyError } from '../../../src/lib/vm/errors'
|
||||
import type { VmManifest } from '../../../src/lib/vm/manifest'
|
||||
import {
|
||||
getCachedManifestPath,
|
||||
getContainerdSocketPath,
|
||||
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
|
||||
let socketServer: ReturnType<typeof Bun.listen> | null
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp('/tmp/vmrt-')
|
||||
limaHome = join(root, 'lima')
|
||||
logPath = join(root, 'limactl.log')
|
||||
templatePath = join(root, 'browseros-vm.yaml')
|
||||
socketServer = null
|
||||
await writeCachedManifest(root)
|
||||
await writeFile(templatePath, 'minimumLimaVersion: 2.0.0\nmounts: []\n')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
socketServer?.stop(true)
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('provisions a fresh VM, waits for the socket, and installs the manifest', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ list: { stdout: '' }, create: {}, start: {} },
|
||||
logPath,
|
||||
)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
socketServer = await createSocket(getContainerdSocketPath(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}`)
|
||||
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 runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
browserosRoot: root,
|
||||
})
|
||||
socketServer = await createSocket(getContainerdSocketPath(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 runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
browserosRoot: root,
|
||||
})
|
||||
socketServer = await createSocket(getContainerdSocketPath(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 fakeSsh({ stdout: 'provisioned:old\n' }, logPath)
|
||||
await mkdir(join(limaHome, VM_NAME), { recursive: true })
|
||||
await writeFile(join(limaHome, VM_NAME, 'ssh.config'), '')
|
||||
setTimeout(() => {
|
||||
void createSocket(getContainerdSocketPath(root)).then((server) => {
|
||||
socketServer = server
|
||||
})
|
||||
}, 10)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
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 the socket never appears', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ list: { stdout: '' }, create: {}, start: {} },
|
||||
logPath,
|
||||
)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
socketTimeoutMs: 10,
|
||||
socketPollMs: 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 runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
socketServer = await createSocket(getContainerdSocketPath(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('preserves a newer installed manifest when cached manifest is older', 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 runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
socketServer = await createSocket(getContainerdSocketPath(root))
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(await readInstalledUpdatedAt(root)).toBe('2026-04-23T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('does not auto-reset when socket readiness fails', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ list: { stdout: '' }, create: {}, start: {} },
|
||||
logPath,
|
||||
)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
socketTimeoutMs: 10,
|
||||
socketPollMs: 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 and listRunningContainers through ssh', async () => {
|
||||
const sshPath = await fakeSsh({ stdout: 'gateway\nworker\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.runCommand(['nerdctl', 'version'])).resolves.toBe(0)
|
||||
await expect(runtime.listRunningContainers()).resolves.toEqual([
|
||||
'gateway',
|
||||
'worker',
|
||||
])
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
expect(log).toContain(
|
||||
`ARGS:-F ${sshConfig} lima-${VM_NAME} 'nerdctl' 'version'`,
|
||||
)
|
||||
expect(log).toContain(
|
||||
`ARGS:-F ${sshConfig} lima-${VM_NAME} 'nerdctl' 'ps' '--format' '{{.Names}}'`,
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it('returns a stop handle for tailing container logs', async () => {
|
||||
const sshPath = await fakeSsh({ stdout: 'line\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,
|
||||
})
|
||||
const lines: string[] = []
|
||||
|
||||
const stop = runtime.tailContainerLogs('gateway', (line) =>
|
||||
lines.push(line),
|
||||
)
|
||||
await Bun.sleep(20)
|
||||
stop()
|
||||
|
||||
expect(lines).toEqual(['line'])
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
`ARGS:-F ${sshConfig} lima-${VM_NAME} 'nerdctl' 'logs' '-f' '-n' '0' 'gateway'`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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 createSocket(
|
||||
path: string,
|
||||
): Promise<ReturnType<typeof Bun.listen>> {
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
return Bun.listen({
|
||||
unix: path,
|
||||
socket: {
|
||||
data() {},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { afterEach, describe, expect, it, mock, spyOn } 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 createHttpServer = mock(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 cdpConnect = mock(async () => {})
|
||||
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'],
|
||||
},
|
||||
}))
|
||||
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 { Application } = await import('../src/main')
|
||||
const app = new Application(config)
|
||||
@@ -107,9 +107,14 @@ 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()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it } from 'bun:test'
|
||||
import { afterAll, 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,
|
||||
@@ -16,7 +17,9 @@ 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 { withBrowser } from '../__helpers__/with-browser'
|
||||
import { cleanupWithBrowser, withBrowser } from '../__helpers__/with-browser'
|
||||
|
||||
process.env.ACL_EMBEDDING_DISABLE = 'true'
|
||||
|
||||
function textOf(result: {
|
||||
content: { type: string; text?: string }[]
|
||||
@@ -52,6 +55,72 @@ 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>
|
||||
@@ -89,6 +158,11 @@ 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 }) => {
|
||||
@@ -410,7 +484,7 @@ describe('input tools', () => {
|
||||
{
|
||||
id: 'submit-rule',
|
||||
sitePattern: '*',
|
||||
description: 'submit',
|
||||
textMatch: 'Submit',
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
@@ -437,7 +511,7 @@ describe('input tools', () => {
|
||||
{
|
||||
id: 'submit-rule',
|
||||
sitePattern: '*',
|
||||
description: 'submit',
|
||||
textMatch: 'Submit',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
@@ -457,24 +531,7 @@ describe('input tools', () => {
|
||||
)
|
||||
const pageId = pageIdOf(newResult)
|
||||
|
||||
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 buttonPoint = await pointInsideElement(ctx, pageId, 'submit-btn')
|
||||
|
||||
const blockedClick = await executeTool(
|
||||
click_at,
|
||||
@@ -492,24 +549,7 @@ describe('input tools', () => {
|
||||
},
|
||||
]
|
||||
|
||||
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 inputPoint = await pointInsideElement(ctx, pageId, 'name')
|
||||
|
||||
const blockedType = await executeTool(
|
||||
type_at,
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
},
|
||||
"apps/server": {
|
||||
"name": "@browseros/server",
|
||||
"version": "0.0.85",
|
||||
"version": "0.0.88",
|
||||
"bin": {
|
||||
"browseros-server": "./src/index.ts",
|
||||
},
|
||||
@@ -232,6 +232,17 @@
|
||||
"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",
|
||||
@@ -463,6 +474,8 @@
|
||||
|
||||
"@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"],
|
||||
@@ -4489,6 +4502,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -5213,6 +5228,100 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -5597,6 +5706,70 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -5705,6 +5878,22 @@
|
||||
|
||||
"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=="],
|
||||
@@ -5733,6 +5922,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -27,10 +27,17 @@
|
||||
"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:tools && bun run test:integration",
|
||||
"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: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": "echo 'SDK tests disabled: test environment does not provide the extract/verify LLM service'",
|
||||
"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",
|
||||
"typecheck": "bun run --filter '*' typecheck",
|
||||
"lint": "bunx biome check",
|
||||
"lint:fix": "bunx biome check --write --unsafe",
|
||||
|
||||
11
packages/browseros-agent/packages/build-tools/.env.sample
Normal file
11
packages/browseros-agent/packages/build-tools/.env.sample
Normal file
@@ -0,0 +1,11 @@
|
||||
# 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
|
||||
79
packages/browseros-agent/packages/build-tools/README.md
Normal file
79
packages/browseros-agent/packages/build-tools/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# @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
|
||||
bun run --filter @browseros/build-tools build:tarball -- --agent openclaw --arch arm64
|
||||
NODE_ENV=development bun run --filter @browseros/build-tools cache:sync:dev
|
||||
```
|
||||
|
||||
`cache:sync:dev` hardcodes `arm64` (all devs are on Apple Silicon), skips R2 entirely, and writes an arm64-only manifest + tarball into `~/.browseros-dev/cache/vm/` from `./dist/`. It refuses to run unless `NODE_ENV=development`. Use this when you want to test the server against a local tarball without publishing.
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"name": "openclaw",
|
||||
"image": "ghcr.io/openclaw/openclaw",
|
||||
"version": "2026.4.12"
|
||||
}
|
||||
]
|
||||
}
|
||||
25
packages/browseros-agent/packages/build-tools/package.json
Normal file
25
packages/browseros-agent/packages/build-tools/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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",
|
||||
"cache:sync:dev": "bun run scripts/cache-sync-dev.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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/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}`)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bun
|
||||
import { copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import type { Arch } from './common/arch'
|
||||
import {
|
||||
type AgentEntry,
|
||||
type AgentManifest,
|
||||
type Bundle,
|
||||
tarballKey,
|
||||
} from './common/manifest'
|
||||
import { sha256File, verifySha256 } from './common/sha256'
|
||||
|
||||
const ARM64: Arch = 'arm64'
|
||||
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
throw new Error(
|
||||
'cache:sync:dev refuses to run without NODE_ENV=development — it writes to ~/.browseros-dev/cache/vm/',
|
||||
)
|
||||
}
|
||||
|
||||
const pkgRoot = path.resolve(import.meta.dir, '..')
|
||||
const distDir = path.join(pkgRoot, 'dist')
|
||||
const bundle = JSON.parse(
|
||||
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
|
||||
) as Bundle
|
||||
|
||||
const cacheRoot = path.join(
|
||||
homedir(),
|
||||
PATHS.DEV_BROWSEROS_DIR_NAME,
|
||||
PATHS.CACHE_DIR_NAME,
|
||||
)
|
||||
const imagesDir = path.join(cacheRoot, 'vm', 'images')
|
||||
const manifestPath = path.join(cacheRoot, 'vm', 'manifest.json')
|
||||
await mkdir(imagesDir, { recursive: true })
|
||||
|
||||
const agents: Record<string, AgentEntry> = {}
|
||||
for (const agent of bundle.agents) {
|
||||
const key = tarballKey(agent.name, agent.version, ARM64)
|
||||
const srcTarball = path.join(distDir, 'images', path.basename(key))
|
||||
await assertExists(srcTarball)
|
||||
|
||||
const sha256 = await sha256File(srcTarball)
|
||||
const sizeBytes = (await stat(srcTarball)).size
|
||||
const destTarball = path.join(cacheRoot, key)
|
||||
|
||||
if (await matchesExisting(destTarball, sha256)) {
|
||||
console.log(`cache hit: ${key}`)
|
||||
} else {
|
||||
await mkdir(path.dirname(destTarball), { recursive: true })
|
||||
await copyFile(srcTarball, destTarball)
|
||||
await verifySha256(destTarball, sha256)
|
||||
console.log(`seeded ${key}`)
|
||||
}
|
||||
|
||||
agents[agent.name] = {
|
||||
image: agent.image,
|
||||
version: agent.version,
|
||||
tarballs: { arm64: { key, sha256, sizeBytes } } as AgentEntry['tarballs'],
|
||||
}
|
||||
}
|
||||
|
||||
const manifest: AgentManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: new Date().toISOString(),
|
||||
agents,
|
||||
}
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
console.log(`manifest written to ${manifestPath}`)
|
||||
|
||||
async function assertExists(filePath: string): Promise<void> {
|
||||
try {
|
||||
await stat(filePath)
|
||||
} catch {
|
||||
throw new Error(
|
||||
`missing ${filePath} — run: bun run build:tarball -- --agent <name> --arch arm64`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function matchesExisting(
|
||||
filePath: string,
|
||||
expectedSha: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await stat(filePath)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return (await sha256File(filePath)) === expectedSha
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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'
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
|
||||
export async function sha256File(path: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(path)) {
|
||||
hash.update(chunk)
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
export async function verifySha256(
|
||||
path: string,
|
||||
expected: string,
|
||||
): Promise<void> {
|
||||
const actual = await sha256File(path)
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`sha256 mismatch for ${path}: expected ${expected}, got ${actual}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user