mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
Compare commits
30 Commits
fix/test-g
...
feat/aent-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b3b22ce17 | ||
|
|
19fff97a9c | ||
|
|
25c027863c | ||
|
|
8440ae09ce | ||
|
|
95f34da014 | ||
|
|
fe9913a4fe | ||
|
|
ecba7de221 | ||
|
|
123a13fe62 | ||
|
|
5ccdbaf87f | ||
|
|
0650f21c80 | ||
|
|
e80ec467f4 | ||
|
|
41374439c4 | ||
|
|
ad99cd6cc1 | ||
|
|
47fc9e1292 | ||
|
|
2a61dcbc58 | ||
|
|
f5a2b7315c | ||
|
|
6de3b3422c | ||
|
|
224b6cd3a8 | ||
|
|
7baee8d57e | ||
|
|
e8e8c36fdb | ||
|
|
3810005457 | ||
|
|
688f7962cb | ||
|
|
526d784d82 | ||
|
|
331fec07e6 | ||
|
|
0652ee8ca8 | ||
|
|
156f5dbc5d | ||
|
|
ebd3200cfe | ||
|
|
4172daa130 | ||
|
|
c1b1e53a86 | ||
|
|
d653883e99 |
170
.github/workflows/build-agent-container.yml
vendored
Normal file
170
.github/workflows/build-agent-container.yml
vendored
Normal file
@@ -0,0 +1,170 @@
|
||||
name: Build Agent Container
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
agent:
|
||||
description: Optional agent filter from recipe/agents.json
|
||||
required: false
|
||||
type: string
|
||||
version:
|
||||
description: Optional dry-run version override for the selected agent
|
||||
required: false
|
||||
type: string
|
||||
publish:
|
||||
description: Publish artifacts to R2
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/build-agent-container.yml
|
||||
- packages/browseros-agent/packages/agent-container/**
|
||||
schedule:
|
||||
- cron: '0 7 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
list-matrix:
|
||||
runs-on: ubuntu-24.04
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent
|
||||
outputs:
|
||||
matrix: ${{ steps.matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun ci
|
||||
|
||||
- name: Typecheck package
|
||||
run: bun run --filter @browseros/agent-container typecheck
|
||||
|
||||
- name: Run package tests
|
||||
run: bun run --filter @browseros/agent-container test
|
||||
|
||||
- name: Compute matrix
|
||||
id: matrix
|
||||
env:
|
||||
INPUT_AGENT: ${{ inputs.agent }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=()
|
||||
if [ -n "$INPUT_AGENT" ]; then
|
||||
args+=(--agent "$INPUT_AGENT")
|
||||
fi
|
||||
matrix="$(bun run packages/agent-container/scripts/list-matrix.ts "${args[@]}")"
|
||||
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
needs: list-matrix
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 60
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.list-matrix.outputs.matrix) }}
|
||||
env:
|
||||
ARTIFACT_ROOT: ${{ github.workspace }}/packages/browseros-agent/packages/agent-container/dist/agent-container
|
||||
OUTPUT_DIR: ${{ github.workspace }}/packages/browseros-agent/packages/agent-container/dist/agent-container/${{ matrix.agent }}/${{ matrix.arch }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun ci
|
||||
|
||||
- name: Install podman
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y podman
|
||||
|
||||
- name: Build tarball
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cmd=(
|
||||
bun run --filter @browseros/agent-container build --
|
||||
--agent "${{ matrix.agent }}"
|
||||
--arch "${{ matrix.arch }}"
|
||||
--output-dir "$OUTPUT_DIR"
|
||||
)
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "$INPUT_VERSION" ]; then
|
||||
cmd+=(--version "$INPUT_VERSION")
|
||||
fi
|
||||
"${cmd[@]}"
|
||||
|
||||
- name: Smoke test archive
|
||||
run: |
|
||||
set -euo pipefail
|
||||
result_json="$OUTPUT_DIR/build-result.json"
|
||||
tarball="$(bun -e "const result = await Bun.file(process.argv[1]).json(); console.log(result.tarballPath)" "$result_json")"
|
||||
expected_image="$(bun -e 'const result = await Bun.file(process.argv[1]).json(); console.log(result.image + ":" + result.version)' "$result_json")"
|
||||
expected_fingerprint="$(bun -e "const result = await Bun.file(process.argv[1]).json(); console.log(result.smokeFingerprint)" "$result_json")"
|
||||
bun run --filter @browseros/agent-container smoke -- \
|
||||
--tarball "$tarball" \
|
||||
--expected-image "$expected_image" \
|
||||
--expected-fingerprint "$expected_fingerprint"
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: agent-container-${{ matrix.agent }}-${{ matrix.arch }}
|
||||
path: ${{ env.ARTIFACT_ROOT }}
|
||||
|
||||
publish:
|
||||
needs: build
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish == true }}
|
||||
runs-on: ubuntu-24.04
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent
|
||||
env:
|
||||
ARTIFACT_ROOT: ${{ github.workspace }}/packages/browseros-agent/packages/agent-container/dist/agent-container
|
||||
steps:
|
||||
- name: Guard recipe source of truth
|
||||
if: ${{ inputs.version != '' }}
|
||||
run: |
|
||||
echo "Refusing to publish a workflow_dispatch version override. Update recipe/agents.json instead." >&2
|
||||
exit 1
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun ci
|
||||
|
||||
- name: Download matrix artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: agent-container-*
|
||||
merge-multiple: true
|
||||
path: ${{ env.ARTIFACT_ROOT }}
|
||||
|
||||
- name: Publish to R2
|
||||
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
|
||||
bun run --filter @browseros/agent-container upload -- \
|
||||
--artifact-dir "$ARTIFACT_ROOT" \
|
||||
--update-aggregate
|
||||
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,9 +1,7 @@
|
||||
import type {
|
||||
BrowserOSCustomRoleInput,
|
||||
BrowserOSRoleBoundary,
|
||||
} from '@browseros/shared/types/role-aware-agents'
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
@@ -35,53 +33,26 @@ 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,
|
||||
usePodmanOverrides,
|
||||
} 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<
|
||||
@@ -267,6 +238,122 @@ const ProviderSelector: FC<ProviderSelectorProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
const PodmanOverridesCard: FC = () => {
|
||||
const { overrides, loading, saving, error, saveOverrides, clearOverrides } =
|
||||
usePodmanOverrides()
|
||||
|
||||
const [value, setValue] = useState('')
|
||||
const [touched, setTouched] = useState(false)
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!touched && overrides) setValue(overrides.podmanPath ?? '')
|
||||
}, [overrides, touched])
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return
|
||||
setLocalError(null)
|
||||
try {
|
||||
await saveOverrides(trimmed)
|
||||
setTouched(false)
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = async () => {
|
||||
setLocalError(null)
|
||||
try {
|
||||
await clearOverrides()
|
||||
setValue('')
|
||||
setTouched(false)
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const hasOverride = !!overrides?.podmanPath
|
||||
const effective = overrides?.effectivePodmanPath ?? null
|
||||
const inlineErrorMessage = localError ?? error?.message ?? null
|
||||
|
||||
const body = (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="podman-path" className="font-medium text-sm">
|
||||
Podman binary path
|
||||
</label>
|
||||
<Input
|
||||
id="podman-path"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setTouched(true)
|
||||
setValue(event.target.value)
|
||||
}}
|
||||
placeholder="/opt/homebrew/bin/podman"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Install Podman yourself (e.g. <code>brew install podman</code>) and
|
||||
paste the absolute path to the binary. Restart the gateway after
|
||||
saving.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{effective && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Currently using: <code className="break-all">{effective}</code>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{inlineErrorMessage && (
|
||||
<p className="text-destructive text-xs">{inlineErrorMessage}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading || !value.trim()}
|
||||
>
|
||||
{saving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleClear}
|
||||
disabled={saving || loading || !hasOverride}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer py-3"
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{collapsed ? (
|
||||
<ChevronRight className="size-4" />
|
||||
) : (
|
||||
<ChevronDown className="size-4" />
|
||||
)}
|
||||
Advanced: Podman binary path
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{!collapsed && <CardContent className="pt-0">{body}</CardContent>}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export const AgentsPage: FC = () => {
|
||||
const {
|
||||
status,
|
||||
@@ -281,7 +368,6 @@ export const AgentsPage: FC = () => {
|
||||
loading: agentsLoading,
|
||||
error: agentsError,
|
||||
} = useOpenClawAgents(agentsQueryEnabled)
|
||||
const { roles, loading: rolesLoading, error: rolesError } = useOpenClawRoles()
|
||||
const {
|
||||
setupOpenClaw,
|
||||
createAgent,
|
||||
@@ -295,48 +381,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 +413,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 +453,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 +494,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 +506,6 @@ export const AgentsPage: FC = () => {
|
||||
})
|
||||
setCreateOpen(false)
|
||||
setNewName('')
|
||||
setCustomRole({
|
||||
name: '',
|
||||
shortDescription: '',
|
||||
longDescription: '',
|
||||
recommendedApps: [],
|
||||
boundaries: createDefaultCustomRoleBoundaries(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -619,7 +620,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 +631,13 @@ export const AgentsPage: FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lifecycleBanner && (
|
||||
<Alert>
|
||||
<Loader2 className="animate-spin" />
|
||||
<AlertTitle>{lifecycleBanner}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{inlineError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle />
|
||||
@@ -649,7 +657,7 @@ export const AgentsPage: FC = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status && gatewayUiState.controlPlaneDegraded && (
|
||||
{status && showControlPlaneDegraded && (
|
||||
<Alert
|
||||
variant={
|
||||
status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'
|
||||
@@ -770,7 +778,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 +796,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 +807,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 +817,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>
|
||||
@@ -832,6 +830,8 @@ export const AgentsPage: FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PodmanOverridesCard />
|
||||
|
||||
<Dialog open={setupOpen} onOpenChange={setSetupOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -868,246 +868,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 +901,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,21 @@ export function getModelDisplayName(model: unknown): string | undefined {
|
||||
export const OPENCLAW_QUERY_KEYS = {
|
||||
status: 'openclaw-status',
|
||||
agents: 'openclaw-agents',
|
||||
roles: 'openclaw-roles',
|
||||
podmanOverrides: 'openclaw-podman-overrides',
|
||||
} as const
|
||||
|
||||
export interface PodmanOverrides {
|
||||
podmanPath: string | null
|
||||
effectivePodmanPath: string
|
||||
}
|
||||
|
||||
export type GatewayLifecycleAction =
|
||||
| 'setup'
|
||||
| 'start'
|
||||
| 'stop'
|
||||
| 'restart'
|
||||
| 'reconnect'
|
||||
|
||||
async function clawFetch<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
@@ -117,16 +102,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 +154,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 +231,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 +258,51 @@ export function useOpenClawMutations() {
|
||||
creating: createMutation.isPending,
|
||||
deleting: deleteMutation.isPending,
|
||||
reconnecting: reconnectMutation.isPending,
|
||||
pendingGatewayAction,
|
||||
}
|
||||
}
|
||||
|
||||
export function usePodmanOverrides() {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const query = useQuery<PodmanOverrides, Error>({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.podmanOverrides, baseUrl],
|
||||
queryFn: () =>
|
||||
clawFetch<PodmanOverrides>(baseUrl as string, '/podman-overrides'),
|
||||
enabled: !!baseUrl && !urlLoading,
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (podmanPath: string | null) =>
|
||||
clawFetch<PodmanOverrides>(baseUrl as string, '/podman-overrides', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ podmanPath }),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.podmanOverrides],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.status],
|
||||
}),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
overrides: query.data ?? null,
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: (query.error ?? urlError) as Error | null,
|
||||
saving: saveMutation.isPending,
|
||||
saveOverrides: (podmanPath: string) => saveMutation.mutateAsync(podmanPath),
|
||||
clearOverrides: () => saveMutation.mutateAsync(null),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,17 +319,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,48 @@
|
||||
* 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 { accessSync, existsSync, constants as fsConstants } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
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,
|
||||
} 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 getCreateAgentValidationError(body: { name?: string }): string | null {
|
||||
if (!body.name?.trim()) {
|
||||
return 'Name is required'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
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 getPodmanOverrideValidationError(body: {
|
||||
podmanPath?: string | null
|
||||
}): string | null {
|
||||
if (body.podmanPath === null) return null
|
||||
if (typeof body.podmanPath !== 'string' || !body.podmanPath.trim()) {
|
||||
return 'podmanPath must be a non-empty absolute path or null'
|
||||
}
|
||||
if (!path.isAbsolute(body.podmanPath)) {
|
||||
return 'podmanPath must be an absolute path'
|
||||
}
|
||||
if (!existsSync(body.podmanPath)) {
|
||||
return `File does not exist: ${body.podmanPath}`
|
||||
}
|
||||
try {
|
||||
accessSync(body.podmanPath, fsConstants.X_OK)
|
||||
} catch {
|
||||
return `File is not executable: ${body.podmanPath}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function createOpenClawRoutes() {
|
||||
@@ -72,7 +82,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,6 +99,9 @@ export function createOpenClawRoutes() {
|
||||
providerType: body.providerType,
|
||||
providerName: body.providerName,
|
||||
})
|
||||
if (isUnsupportedOpenClawProviderError(err)) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
if (message.includes('Podman is not available')) {
|
||||
return c.json({ error: message }, 503)
|
||||
}
|
||||
@@ -154,97 +167,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 +198,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 +229,7 @@ export function createOpenClawRoutes() {
|
||||
const body = await c.req.json<{
|
||||
message: string
|
||||
sessionKey?: string
|
||||
history?: MonitoringChatTurn[]
|
||||
}>()
|
||||
|
||||
if (!body.message?.trim()) {
|
||||
@@ -294,12 +237,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 +277,68 @@ 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)
|
||||
}
|
||||
@@ -352,14 +368,52 @@ 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)
|
||||
}
|
||||
})
|
||||
|
||||
.get('/podman-overrides', async (c) => {
|
||||
try {
|
||||
const overrides = await getOpenClawService().getPodmanOverrides()
|
||||
return c.json(overrides)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.error('Podman overrides read failed', { error: message })
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/podman-overrides', async (c) => {
|
||||
const body = await c.req.json<{ podmanPath: string | null }>()
|
||||
const validationError = getPodmanOverrideValidationError(body)
|
||||
if (validationError) {
|
||||
return c.json({ error: validationError }, 400)
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('OpenClaw podman override requested', {
|
||||
podmanPath: body.podmanPath,
|
||||
})
|
||||
const result = await getOpenClawService().applyPodmanOverrides({
|
||||
podmanPath: body.podmanPath,
|
||||
})
|
||||
return c.json(result)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.error('Podman overrides apply failed', { error: message })
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,6 +29,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'
|
||||
@@ -121,6 +122,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 +148,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,
|
||||
|
||||
@@ -3,21 +3,27 @@
|
||||
* 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.
|
||||
* OpenClaw container lifecycle abstraction over PodmanRuntime.
|
||||
*/
|
||||
|
||||
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 { logger } from '../../../lib/logger'
|
||||
import type { LogFn, PodmanRuntime } from './podman-runtime'
|
||||
|
||||
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`
|
||||
|
||||
export type GatewayContainerSpec = {
|
||||
image: string
|
||||
hostPort: number
|
||||
hostHome: string
|
||||
envFilePath: string
|
||||
gatewayToken?: string
|
||||
timezone: string
|
||||
}
|
||||
|
||||
export class ContainerRuntime {
|
||||
constructor(
|
||||
@@ -41,64 +47,102 @@ export class ContainerRuntime {
|
||||
return this.podman.getMachineStatus()
|
||||
}
|
||||
|
||||
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> {
|
||||
const code = await this.runPodmanCommand(['pull', image], onLog)
|
||||
if (code !== 0) throw new Error(`image pull failed with code ${code}`)
|
||||
}
|
||||
|
||||
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.ensureGatewayRemoved(onLog)
|
||||
const containerPort = String(OPENCLAW_GATEWAY_CONTAINER_PORT)
|
||||
const code = await this.runPodmanCommand(
|
||||
[
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
'--restart',
|
||||
'unless-stopped',
|
||||
'-p',
|
||||
`127.0.0.1:${input.hostPort}:${containerPort}`,
|
||||
...this.buildGatewayContainerRuntimeArgs(input),
|
||||
'--health-cmd',
|
||||
`curl -sf http://127.0.0.1:${containerPort}/healthz`,
|
||||
'--health-interval',
|
||||
'30s',
|
||||
'--health-timeout',
|
||||
'10s',
|
||||
'--health-retries',
|
||||
'3',
|
||||
input.image,
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'gateway',
|
||||
'--bind',
|
||||
'lan',
|
||||
'--port',
|
||||
containerPort,
|
||||
'--allow-unconfigured',
|
||||
],
|
||||
onLog,
|
||||
)
|
||||
if (code !== 0) throw new Error(`gateway start failed with code ${code}`)
|
||||
}
|
||||
|
||||
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> {
|
||||
const code = await this.removeGatewayContainer(onLog)
|
||||
if (code !== 0) {
|
||||
throw new Error(`gateway stop failed with code ${code}`)
|
||||
}
|
||||
}
|
||||
|
||||
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.runPodmanCommand(
|
||||
['logs', '--tail', String(tail), OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
(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)) {
|
||||
if (await this.isReady(hostPort)) {
|
||||
logger.info('OpenClaw gateway became ready', {
|
||||
port,
|
||||
hostPort,
|
||||
waitMs: Date.now() - start,
|
||||
})
|
||||
return true
|
||||
@@ -106,22 +150,12 @@ export class ContainerRuntime {
|
||||
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.
|
||||
@@ -132,8 +166,8 @@ export class ContainerRuntime {
|
||||
|
||||
try {
|
||||
const containers = await this.podman.listRunningContainers()
|
||||
const allOurs = containers.every((name) =>
|
||||
name.startsWith(OPENCLAW_COMPOSE_PROJECT_NAME),
|
||||
const allOurs = containers.every(
|
||||
(name) => name === OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
)
|
||||
|
||||
if (containers.length === 0 || allOurs) {
|
||||
@@ -153,6 +187,32 @@ export class ContainerRuntime {
|
||||
)
|
||||
}
|
||||
|
||||
async runGatewaySetupCommand(
|
||||
command: string[],
|
||||
spec: GatewayContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<number> {
|
||||
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
|
||||
await this.runPodmanCommand(
|
||||
['rm', '-f', '--ignore', setupContainerName],
|
||||
onLog,
|
||||
)
|
||||
const setupArgs = command[0] === 'node' ? command.slice(1) : command
|
||||
return this.runPodmanCommand(
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'--name',
|
||||
setupContainerName,
|
||||
...this.buildGatewayContainerRuntimeArgs(spec),
|
||||
spec.image,
|
||||
'node',
|
||||
...setupArgs,
|
||||
],
|
||||
onLog,
|
||||
)
|
||||
}
|
||||
|
||||
tailGatewayLogs(onLine: LogFn): () => void {
|
||||
return this.podman.tailContainerLogs(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
@@ -160,15 +220,17 @@ export class ContainerRuntime {
|
||||
)
|
||||
}
|
||||
|
||||
private async compose(args: string[], onLog?: LogFn): Promise<number> {
|
||||
private async runPodmanCommand(
|
||||
args: string[],
|
||||
onLog?: LogFn,
|
||||
): Promise<number> {
|
||||
const lines: string[] = []
|
||||
const command = ['podman', 'compose', ...args].join(' ')
|
||||
logger.info('Running OpenClaw compose command', {
|
||||
const command = ['podman', ...args].join(' ')
|
||||
logger.info('Running OpenClaw podman command', {
|
||||
command,
|
||||
})
|
||||
const code = await this.podman.runCommand(['compose', ...args], {
|
||||
const code = await this.podman.runCommand(args, {
|
||||
cwd: this.projectDir,
|
||||
env: { COMPOSE_PROJECT_NAME: OPENCLAW_COMPOSE_PROJECT_NAME },
|
||||
onOutput: (line) => {
|
||||
lines.push(line)
|
||||
onLog?.(line)
|
||||
@@ -176,17 +238,58 @@ export class ContainerRuntime {
|
||||
})
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error('OpenClaw compose command failed', {
|
||||
logger.error('OpenClaw podman command failed', {
|
||||
command,
|
||||
exitCode: code,
|
||||
output: lines,
|
||||
})
|
||||
} else {
|
||||
logger.info('OpenClaw compose command succeeded', {
|
||||
logger.info('OpenClaw podman command succeeded', {
|
||||
command,
|
||||
})
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
private async ensureGatewayRemoved(onLog?: LogFn): Promise<void> {
|
||||
await this.removeGatewayContainer(onLog)
|
||||
}
|
||||
|
||||
private async removeGatewayContainer(onLog?: LogFn): Promise<number> {
|
||||
return this.runPodmanCommand(
|
||||
['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
onLog,
|
||||
)
|
||||
}
|
||||
|
||||
private buildGatewayContainerRuntimeArgs(
|
||||
input: GatewayContainerSpec,
|
||||
): string[] {
|
||||
return [
|
||||
'--env-file',
|
||||
input.envFilePath,
|
||||
'-e',
|
||||
`HOME=${GATEWAY_CONTAINER_HOME}`,
|
||||
'-e',
|
||||
`OPENCLAW_HOME=${GATEWAY_CONTAINER_HOME}`,
|
||||
'-e',
|
||||
`OPENCLAW_STATE_DIR=${GATEWAY_STATE_DIR}`,
|
||||
'-e',
|
||||
'OPENCLAW_NO_RESPAWN=1',
|
||||
'-e',
|
||||
'NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache',
|
||||
'-e',
|
||||
'NODE_ENV=production',
|
||||
'-e',
|
||||
`TZ=${input.timezone}`,
|
||||
'-v',
|
||||
`${input.hostHome}:${GATEWAY_CONTAINER_HOME}`,
|
||||
'--add-host',
|
||||
'host.containers.internal:host-gateway',
|
||||
...(input.gatewayToken
|
||||
? ['-e', `OPENCLAW_GATEWAY_TOKEN=${input.gatewayToken}`]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,264 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { createParser, type EventSourceMessage } from 'eventsource-parser'
|
||||
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 class OpenClawHttpChatClient {
|
||||
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)
|
||||
}
|
||||
|
||||
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}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Persistence for user-supplied Podman runtime overrides.
|
||||
* Temporary escape hatch so users can point BrowserOS at their own Podman
|
||||
* (e.g. `brew install podman`) when the bundled runtime doesn't resolve helpers.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export interface PodmanOverrides {
|
||||
podmanPath: string | null
|
||||
}
|
||||
|
||||
const OVERRIDES_FILE_NAME = 'podman-overrides.json'
|
||||
|
||||
export function getPodmanOverridesPath(openclawDir: string): string {
|
||||
return join(openclawDir, OVERRIDES_FILE_NAME)
|
||||
}
|
||||
|
||||
export async function loadPodmanOverrides(
|
||||
openclawDir: string,
|
||||
): Promise<PodmanOverrides> {
|
||||
const overridesPath = getPodmanOverridesPath(openclawDir)
|
||||
if (!existsSync(overridesPath)) return { podmanPath: null }
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
await readFile(overridesPath, 'utf-8'),
|
||||
) as Partial<PodmanOverrides>
|
||||
return {
|
||||
podmanPath:
|
||||
typeof parsed.podmanPath === 'string' && parsed.podmanPath.length > 0
|
||||
? parsed.podmanPath
|
||||
: null,
|
||||
}
|
||||
} catch {
|
||||
return { podmanPath: null }
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePodmanOverrides(
|
||||
openclawDir: string,
|
||||
overrides: PodmanOverrides,
|
||||
): Promise<void> {
|
||||
await mkdir(openclawDir, { recursive: true })
|
||||
await writeFile(
|
||||
getPodmanOverridesPath(openclawDir),
|
||||
`${JSON.stringify(overrides, null, 2)}\n`,
|
||||
)
|
||||
}
|
||||
@@ -37,7 +37,6 @@ export function resolveBundledPodmanPath(
|
||||
|
||||
export class PodmanRuntime {
|
||||
private podmanPath: string
|
||||
private machineReady = false
|
||||
|
||||
constructor(config?: { podmanPath?: string }) {
|
||||
this.podmanPath = config?.podmanPath ?? 'podman'
|
||||
@@ -99,9 +98,9 @@ export class PodmanRuntime {
|
||||
'machine',
|
||||
'init',
|
||||
'--cpus',
|
||||
'2',
|
||||
'8',
|
||||
'--memory',
|
||||
'2048',
|
||||
'8096',
|
||||
'--disk-size',
|
||||
'10',
|
||||
],
|
||||
@@ -138,12 +137,9 @@ export class PodmanRuntime {
|
||||
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) {
|
||||
@@ -155,8 +151,6 @@ export class PodmanRuntime {
|
||||
onLog?.('Starting Podman machine...')
|
||||
await this.startMachine(onLog)
|
||||
}
|
||||
|
||||
this.machineReady = true
|
||||
}
|
||||
|
||||
async runCommand(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -6,8 +6,19 @@ import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import type { ServerDiscoveryConfig } from '@browseros/shared/types/server-config'
|
||||
import { logger } from './logger'
|
||||
|
||||
const DEV_BROWSEROS_DIR_NAME = '.browseros-dev'
|
||||
|
||||
export function getBrowserosDir(): string {
|
||||
return join(homedir(), PATHS.BROWSEROS_DIR_NAME)
|
||||
const dirName =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? 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 {
|
||||
@@ -38,6 +49,18 @@ export function getOpenClawDir(): string {
|
||||
return join(getBrowserosDir(), PATHS.OPENCLAW_DIR_NAME)
|
||||
}
|
||||
|
||||
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 +80,12 @@ 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 })
|
||||
}
|
||||
|
||||
export async function cleanOldSessions(): Promise<void> {
|
||||
|
||||
@@ -13,7 +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 {
|
||||
configureOpenClawService,
|
||||
getOpenClawService,
|
||||
} from './api/services/openclaw/openclaw-service'
|
||||
import { loadPodmanOverrides } from './api/services/openclaw/podman-overrides'
|
||||
import { configurePodmanRuntime } from './api/services/openclaw/podman-runtime'
|
||||
import { CdpBackend } from './browser/backends/cdp'
|
||||
import { Browser } from './browser/browser'
|
||||
@@ -22,6 +26,7 @@ import { INLINED_ENV } from './env'
|
||||
import {
|
||||
cleanOldSessions,
|
||||
ensureBrowserosDir,
|
||||
getOpenClawDir,
|
||||
removeServerConfigSync,
|
||||
writeServerConfig,
|
||||
} from './lib/browseros-dir'
|
||||
@@ -56,9 +61,17 @@ export class Application {
|
||||
resourcesDir: path.resolve(this.config.resourcesDir),
|
||||
})
|
||||
|
||||
const resourcesDir = path.resolve(this.config.resourcesDir)
|
||||
const podmanOverrides = await loadPodmanOverrides(getOpenClawDir())
|
||||
configurePodmanRuntime({
|
||||
resourcesDir: path.resolve(this.config.resourcesDir),
|
||||
resourcesDir,
|
||||
podmanPath: podmanOverrides.podmanPath ?? undefined,
|
||||
})
|
||||
if (podmanOverrides.podmanPath) {
|
||||
logger.info('Using user-overridden Podman binary', {
|
||||
podmanPath: podmanOverrides.podmanPath,
|
||||
})
|
||||
}
|
||||
await this.initCoreServices()
|
||||
|
||||
if (!this.config.cdpPort) {
|
||||
@@ -123,7 +136,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,146 @@
|
||||
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 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: process.env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
return result.status ?? 1
|
||||
}
|
||||
|
||||
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 = [process.execPath, '--env-file=.env.development', 'test']
|
||||
if (junitPath) {
|
||||
const outputPath = resolve(projectRoot, junitPath)
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
cmd.push('--reporter=junit', `--reporter-outfile=${outputPath}`)
|
||||
}
|
||||
cmd.push(...targets)
|
||||
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)
|
||||
}
|
||||
|
||||
const requestedGroup = process.argv[2] ?? 'all'
|
||||
|
||||
process.exit(runGroup(requestedGroup))
|
||||
@@ -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 { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
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('returns the current podman overrides on GET', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const getPodmanOverrides = mock(async () => ({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
effectivePodmanPath: '/opt/homebrew/bin/podman',
|
||||
}))
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ getPodmanOverrides }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/podman-overrides')
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
effectivePodmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a relative podman path on POST', async () => {
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/podman-overrides', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ podmanPath: 'podman' }),
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'podmanPath must be an absolute path',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a nonexistent podman path on POST', async () => {
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/podman-overrides', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ podmanPath: '/does/not/exist/podman' }),
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'File does not exist: /does/not/exist/podman',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a non-executable podman path on POST', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'openclaw-route-'))
|
||||
const nonExec = join(tempDir, 'podman')
|
||||
writeFileSync(nonExec, 'not a binary')
|
||||
chmodSync(nonExec, 0o644)
|
||||
try {
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/podman-overrides', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ podmanPath: nonExec }),
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
error: `File is not executable: ${nonExec}`,
|
||||
})
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('applies and echoes when POST clears the override', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const applyPodmanOverrides = mock(async () => ({
|
||||
podmanPath: null,
|
||||
effectivePodmanPath: 'podman',
|
||||
}))
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ applyPodmanOverrides }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/podman-overrides', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ podmanPath: null }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(applyPodmanOverrides).toHaveBeenCalledWith({ podmanPath: null })
|
||||
expect(await response.json()).toEqual({
|
||||
podmanPath: null,
|
||||
effectivePodmanPath: 'podman',
|
||||
})
|
||||
})
|
||||
|
||||
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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
|
||||
import {
|
||||
parseTerminalClientMessage,
|
||||
serializeTerminalServerMessage,
|
||||
@@ -53,7 +54,7 @@ describe('terminal protocol', () => {
|
||||
expect(
|
||||
buildTerminalExecCommand(
|
||||
'podman',
|
||||
'browseros-openclaw-openclaw-gateway-1',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
TERMINAL_HOME_DIR,
|
||||
),
|
||||
).toEqual([
|
||||
@@ -62,7 +63,7 @@ describe('terminal protocol', () => {
|
||||
'-it',
|
||||
'-w',
|
||||
'/home/node/.openclaw',
|
||||
'browseros-openclaw-openclaw-gateway-1',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
'/bin/sh',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } 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: '/tmp/openclaw',
|
||||
envFilePath: '/tmp/openclaw/.openclaw/.env',
|
||||
gatewayToken: 'token-123',
|
||||
timezone: 'America/Los_Angeles',
|
||||
}
|
||||
|
||||
function createRuntime(
|
||||
runCommand: (
|
||||
args: string[],
|
||||
options?: { cwd?: string; onOutput?: (line: string) => void },
|
||||
) => Promise<number>,
|
||||
listRunningContainers: () => Promise<string[]> = async () => [],
|
||||
stopMachine: () => Promise<void> = async () => {},
|
||||
): ContainerRuntime {
|
||||
return new ContainerRuntime(
|
||||
{
|
||||
ensureReady: async () => {},
|
||||
isPodmanAvailable: async () => true,
|
||||
getMachineStatus: async () => ({ initialized: true, running: true }),
|
||||
runCommand,
|
||||
tailContainerLogs: () => () => {},
|
||||
listRunningContainers,
|
||||
stopMachine,
|
||||
} as never,
|
||||
PROJECT_DIR,
|
||||
)
|
||||
}
|
||||
|
||||
function expectedGatewayRuntimeArgs(spec: typeof defaultSpec): string[] {
|
||||
return [
|
||||
'--env-file',
|
||||
spec.envFilePath,
|
||||
'-e',
|
||||
'HOME=/home/node',
|
||||
'-e',
|
||||
'OPENCLAW_HOME=/home/node',
|
||||
'-e',
|
||||
'OPENCLAW_STATE_DIR=/home/node/.openclaw',
|
||||
'-e',
|
||||
'OPENCLAW_NO_RESPAWN=1',
|
||||
'-e',
|
||||
'NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache',
|
||||
'-e',
|
||||
'NODE_ENV=production',
|
||||
'-e',
|
||||
`TZ=${spec.timezone}`,
|
||||
'-v',
|
||||
`${spec.hostHome}:/home/node`,
|
||||
'--add-host',
|
||||
'host.containers.internal:host-gateway',
|
||||
'-e',
|
||||
`OPENCLAW_GATEWAY_TOKEN=${spec.gatewayToken}`,
|
||||
]
|
||||
}
|
||||
|
||||
function expectedStartGatewayRunArgs(spec: typeof defaultSpec): string[] {
|
||||
return [
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
'--restart',
|
||||
'unless-stopped',
|
||||
'-p',
|
||||
`127.0.0.1:${spec.hostPort}:18789`,
|
||||
...expectedGatewayRuntimeArgs(spec),
|
||||
'--health-cmd',
|
||||
'curl -sf http://127.0.0.1:18789/healthz',
|
||||
'--health-interval',
|
||||
'30s',
|
||||
'--health-timeout',
|
||||
'10s',
|
||||
'--health-retries',
|
||||
'3',
|
||||
spec.image,
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'gateway',
|
||||
'--bind',
|
||||
'lan',
|
||||
'--port',
|
||||
'18789',
|
||||
'--allow-unconfigured',
|
||||
]
|
||||
}
|
||||
|
||||
describe('ContainerRuntime', () => {
|
||||
it('pullImage runs podman pull for the requested image', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.pullImage('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
args: ['pull', 'ghcr.io/openclaw/openclaw:2026.4.12'],
|
||||
cwd: PROJECT_DIR,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('startGateway removes any existing gateway and runs a fresh container', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.startGateway(defaultSpec)
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]).toEqual({
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
})
|
||||
expect(calls[1]).toEqual({
|
||||
cwd: PROJECT_DIR,
|
||||
args: expectedStartGatewayRunArgs(defaultSpec),
|
||||
})
|
||||
})
|
||||
|
||||
it('runGatewaySetupCommand in direct mode builds a one-off podman run command', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.runGatewaySetupCommand(
|
||||
['node', 'dist/index.js', 'agents', 'list', '--json'],
|
||||
defaultSpec,
|
||||
)
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: [
|
||||
'rm',
|
||||
'-f',
|
||||
'--ignore',
|
||||
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
|
||||
],
|
||||
},
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: [
|
||||
'run',
|
||||
'--rm',
|
||||
'--name',
|
||||
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
|
||||
...expectedGatewayRuntimeArgs(defaultSpec),
|
||||
defaultSpec.image,
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'agents',
|
||||
'list',
|
||||
'--json',
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stopGateway removes the direct runtime container', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.stopGateway()
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stopGateway is idempotent when the managed container is already absent', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
options?.onOutput?.(
|
||||
`Error: no container with name "${OPENCLAW_GATEWAY_CONTAINER_NAME}" found`,
|
||||
)
|
||||
return 0
|
||||
})
|
||||
|
||||
await expect(runtime.stopGateway()).resolves.toBeUndefined()
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('getGatewayLogs tails logs from the direct runtime container', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
options?.onOutput?.('first')
|
||||
options?.onOutput?.('second')
|
||||
return 0
|
||||
})
|
||||
|
||||
const logs = await runtime.getGatewayLogs(25)
|
||||
|
||||
expect(logs).toEqual(['first', 'second'])
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['logs', '--tail', '25', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('restartGateway recreates and launches the direct runtime container', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.restartGateway(defaultSpec)
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
},
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: expectedStartGatewayRunArgs(defaultSpec),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stopMachineIfSafe allows the managed gateway container', async () => {
|
||||
let stopCalls = 0
|
||||
const runtime = createRuntime(
|
||||
async () => 0,
|
||||
async () => [OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
async () => {
|
||||
stopCalls += 1
|
||||
},
|
||||
)
|
||||
|
||||
await runtime.stopMachineIfSafe()
|
||||
|
||||
expect(stopCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('stopMachineIfSafe does not stop machine if non-BrowserOS containers are running', async () => {
|
||||
let stopCalls = 0
|
||||
const runtime = createRuntime(
|
||||
async () => 0,
|
||||
async () => [OPENCLAW_GATEWAY_CONTAINER_NAME, 'postgres-dev'],
|
||||
async () => {
|
||||
stopCalls += 1
|
||||
},
|
||||
)
|
||||
|
||||
await runtime.stopMachineIfSafe()
|
||||
|
||||
expect(stopCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('execInContainer targets the shared gateway container name', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.execInContainer(['node', '--version'])
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: undefined,
|
||||
args: ['exec', OPENCLAW_GATEWAY_CONTAINER_NAME, 'node', '--version'],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('tailGatewayLogs targets the shared gateway container name', () => {
|
||||
const names: string[] = []
|
||||
const runtime = new ContainerRuntime(
|
||||
{
|
||||
ensureReady: async () => {},
|
||||
isPodmanAvailable: async () => true,
|
||||
getMachineStatus: async () => ({ initialized: true, running: true }),
|
||||
runCommand: async () => 0,
|
||||
tailContainerLogs: (containerName: string) => {
|
||||
names.push(containerName)
|
||||
return () => {}
|
||||
},
|
||||
listRunningContainers: async () => [],
|
||||
stopMachine: async () => {},
|
||||
} as never,
|
||||
PROJECT_DIR,
|
||||
)
|
||||
|
||||
const stop = runtime.tailGatewayLogs(() => {})
|
||||
stop()
|
||||
|
||||
expect(names).toEqual([OPENCLAW_GATEWAY_CONTAINER_NAME])
|
||||
})
|
||||
})
|
||||
@@ -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,244 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { OpenClawHttpChatClient } from '../../../../src/api/services/openclaw/openclaw-http-chat-client'
|
||||
|
||||
describe('OpenClawHttpChatClient', () => {
|
||||
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 OpenClawHttpChatClient(
|
||||
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 OpenClawHttpChatClient(
|
||||
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 OpenClawHttpChatClient(
|
||||
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 OpenClawHttpChatClient(
|
||||
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 OpenClawHttpChatClient(
|
||||
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' },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
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
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @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 {
|
||||
getPodmanOverridesPath,
|
||||
loadPodmanOverrides,
|
||||
savePodmanOverrides,
|
||||
} from '../../../../src/api/services/openclaw/podman-overrides'
|
||||
|
||||
describe('podman overrides', () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browseros-podman-ovr-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns null podmanPath when the overrides file is missing', async () => {
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
})
|
||||
|
||||
it('round-trips save and load', async () => {
|
||||
await savePodmanOverrides(tempDir, {
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when the overrides file is malformed JSON', async () => {
|
||||
fs.writeFileSync(getPodmanOverridesPath(tempDir), '{not json')
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
})
|
||||
|
||||
it('treats empty string and wrong types as null', async () => {
|
||||
fs.writeFileSync(
|
||||
getPodmanOverridesPath(tempDir),
|
||||
JSON.stringify({ podmanPath: '' }),
|
||||
)
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
|
||||
fs.writeFileSync(
|
||||
getPodmanOverridesPath(tempDir),
|
||||
JSON.stringify({ podmanPath: 42 }),
|
||||
)
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
})
|
||||
|
||||
it('persists an explicit null', async () => {
|
||||
await savePodmanOverrides(tempDir, { podmanPath: null })
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
expect(fs.existsSync(getPodmanOverridesPath(tempDir))).toBe(true)
|
||||
})
|
||||
|
||||
it('creates the openclaw directory if it does not exist', async () => {
|
||||
const nested = path.join(tempDir, 'does-not-exist')
|
||||
await savePodmanOverrides(nested, { podmanPath: '/usr/local/bin/podman' })
|
||||
expect(fs.existsSync(getPodmanOverridesPath(nested))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -11,9 +11,43 @@ import path from 'node:path'
|
||||
import {
|
||||
configurePodmanRuntime,
|
||||
getPodmanRuntime,
|
||||
PodmanRuntime,
|
||||
resolveBundledPodmanPath,
|
||||
} from '../../../../src/api/services/openclaw/podman-runtime'
|
||||
|
||||
class FakePodmanRuntime extends PodmanRuntime {
|
||||
machineStatuses: Array<{ initialized: boolean; running: boolean }>
|
||||
initCalls = 0
|
||||
startCalls = 0
|
||||
statusCalls = 0
|
||||
|
||||
constructor(statuses: Array<{ initialized: boolean; running: boolean }>) {
|
||||
super({ podmanPath: 'podman' })
|
||||
this.machineStatuses = [...statuses]
|
||||
}
|
||||
|
||||
async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
this.statusCalls += 1
|
||||
return (
|
||||
this.machineStatuses.shift() ?? {
|
||||
initialized: true,
|
||||
running: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async initMachine(): Promise<void> {
|
||||
this.initCalls += 1
|
||||
}
|
||||
|
||||
async startMachine(): Promise<void> {
|
||||
this.startCalls += 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('podman runtime', () => {
|
||||
let tempDir: string
|
||||
|
||||
@@ -80,4 +114,56 @@ describe('podman runtime', () => {
|
||||
|
||||
expect(runtime.getPodmanPath()).toBe('podman')
|
||||
})
|
||||
|
||||
it('ensureReady re-checks machine status on every call', async () => {
|
||||
const runtime = new FakePodmanRuntime([
|
||||
{ initialized: true, running: true },
|
||||
{ initialized: true, running: true },
|
||||
{ initialized: true, running: true },
|
||||
])
|
||||
|
||||
await runtime.ensureReady()
|
||||
await runtime.ensureReady()
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(runtime.statusCalls).toBe(3)
|
||||
expect(runtime.initCalls).toBe(0)
|
||||
expect(runtime.startCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('ensureReady initializes when machine is not present', async () => {
|
||||
const runtime = new FakePodmanRuntime([
|
||||
{ initialized: false, running: false },
|
||||
])
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(runtime.statusCalls).toBe(1)
|
||||
expect(runtime.initCalls).toBe(1)
|
||||
expect(runtime.startCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('ensureReady starts when machine is initialized but stopped', async () => {
|
||||
const runtime = new FakePodmanRuntime([
|
||||
{ initialized: true, running: false },
|
||||
])
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(runtime.initCalls).toBe(0)
|
||||
expect(runtime.startCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('ensureReady detects an externally stopped machine on the next call', async () => {
|
||||
const runtime = new FakePodmanRuntime([
|
||||
{ initialized: true, running: true },
|
||||
{ initialized: true, running: false },
|
||||
])
|
||||
|
||||
await runtime.ensureReady()
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(runtime.statusCalls).toBe(2)
|
||||
expect(runtime.startCalls).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @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 {
|
||||
getBrowserosDir,
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,85 @@ 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 podmanRuntime = await import(
|
||||
'../src/api/services/openclaw/podman-runtime'
|
||||
)
|
||||
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(podmanRuntime, 'configurePodmanRuntime').mockImplementation(() => {})
|
||||
spyOn(openclawService, 'configureOpenClawService').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
tryAutoStart: async () => {},
|
||||
}) as never,
|
||||
)
|
||||
|
||||
const { Application } = await import('../src/main')
|
||||
const app = new Application(config)
|
||||
@@ -107,9 +105,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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
},
|
||||
@@ -216,6 +216,17 @@
|
||||
"chrome-devtools-mcp": "latest",
|
||||
},
|
||||
},
|
||||
"packages/agent-container": {
|
||||
"name": "@browseros/agent-container",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.933.0",
|
||||
"zod": "^3.24.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.3",
|
||||
},
|
||||
},
|
||||
"packages/agent-sdk": {
|
||||
"name": "@browseros-ai/agent-sdk",
|
||||
"version": "0.0.7",
|
||||
@@ -463,6 +474,8 @@
|
||||
|
||||
"@browseros/agent": ["@browseros/agent@workspace:apps/agent"],
|
||||
|
||||
"@browseros/agent-container": ["@browseros/agent-container@workspace:packages/agent-container"],
|
||||
|
||||
"@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/agent-container/@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=="],
|
||||
@@ -5211,6 +5226,100 @@
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"@browseros/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@aws-sdk/client-s3/@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="],
|
||||
|
||||
"@browseros/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
|
||||
"@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=="],
|
||||
@@ -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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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/agent-container/@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",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Required for `bun run --filter @browseros/agent-container upload`
|
||||
R2_ACCOUNT_ID=
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
R2_BUCKET=
|
||||
|
||||
# Optional overrides
|
||||
R2_PUBLIC_BASE_URL=https://cdn.browseros.com
|
||||
PODMAN_BIN=podman
|
||||
|
||||
# Optional recipe-driven registry auth
|
||||
# If an agent entry in recipe/agents.json sets `requires_auth.secret`,
|
||||
# define that env var before running `build`.
|
||||
# EXAMPLE_REGISTRY_TOKEN=
|
||||
52
packages/browseros-agent/packages/agent-container/README.md
Normal file
52
packages/browseros-agent/packages/agent-container/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# @browseros/agent-container
|
||||
|
||||
OCI tarball producer for BrowserOS-bundled agent containers.
|
||||
|
||||
This package owns the WS2 pipeline:
|
||||
|
||||
- Read the active agent set from `recipe/agents.json`
|
||||
- Pull the upstream image with `podman`
|
||||
- Save it as an OCI archive and gzip it
|
||||
- Smoke-test the archive with `podman load`
|
||||
- Publish tarballs, checksum sidecars, and manifests to R2
|
||||
|
||||
Package env requirements are documented in [.env.sample](./.env.sample).
|
||||
|
||||
## Local usage
|
||||
|
||||
```bash
|
||||
cd packages/browseros-agent
|
||||
|
||||
# Print the GitHub Actions matrix JSON
|
||||
bun run --filter @browseros/agent-container list-matrix
|
||||
|
||||
# Build one artifact locally
|
||||
bun run --filter @browseros/agent-container build -- \
|
||||
--agent openclaw \
|
||||
--arch arm64 \
|
||||
--output-dir packages/agent-container/dist/agent-container/openclaw/arm64
|
||||
|
||||
# Smoke-test a built tarball
|
||||
bun run --filter @browseros/agent-container smoke -- \
|
||||
--tarball packages/agent-container/dist/agent-container/openclaw/arm64/openclaw-2026.4.12-arm64.tar.gz \
|
||||
--expected-image ghcr.io/openclaw/openclaw:2026.4.12 \
|
||||
--expected-fingerprint ...
|
||||
|
||||
# Upload pre-built artifacts
|
||||
# Fill these from packages/agent-container/.env.sample
|
||||
R2_ACCOUNT_ID=... \
|
||||
R2_ACCESS_KEY_ID=... \
|
||||
R2_SECRET_ACCESS_KEY=... \
|
||||
R2_BUCKET=... \
|
||||
bun run --filter @browseros/agent-container upload -- \
|
||||
--artifact-dir packages/agent-container/dist/agent-container \
|
||||
--update-aggregate
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `recipe/agents.json` is the source of truth for the active set.
|
||||
- `workflow_dispatch` version overrides are intended for dry runs. Publishing still needs the recipe to be authoritative.
|
||||
- `src/load.ts` is intentionally stubbed. WS6 fills in the runtime consumer path.
|
||||
- Private registry auth is recipe-driven: if `requires_auth.secret` is set for an agent, export that env var before running `build`.
|
||||
- When invoking package scripts with `bun run --filter @browseros/agent-container`, pass artifact paths that are explicit from `packages/browseros-agent`. Filtered scripts run inside the package directory, so bare `dist/...` paths are ambiguous.
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@browseros/agent-container",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "BrowserOS agent container OCI tarball producer",
|
||||
"exports": {
|
||||
"./schema": {
|
||||
"types": "./src/schema/index.ts",
|
||||
"default": "./src/schema/index.ts"
|
||||
},
|
||||
"./load": {
|
||||
"types": "./src/load.ts",
|
||||
"default": "./src/load.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run scripts/build.ts",
|
||||
"upload": "bun run scripts/upload.ts",
|
||||
"smoke": "bun run scripts/smoke.ts",
|
||||
"list-matrix": "bun run scripts/list-matrix.ts",
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.933.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schema": "v1",
|
||||
"agents": [
|
||||
{
|
||||
"name": "openclaw",
|
||||
"image": "ghcr.io/openclaw/openclaw",
|
||||
"version": "2026.4.12",
|
||||
"arches": ["amd64", "arm64"],
|
||||
"publishAs": "openclaw"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
import { buildTarball } from '../src/build'
|
||||
import { readAgentsConfig } from '../src/catalog'
|
||||
import { parseArch } from '../src/schema/arch'
|
||||
|
||||
const packageRoot = resolve(import.meta.dir, '..')
|
||||
const recipePath = resolve(packageRoot, 'recipe', 'agents.json')
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
agent: { type: 'string' },
|
||||
version: { type: 'string' },
|
||||
arch: { type: 'string' },
|
||||
'output-dir': { type: 'string' },
|
||||
help: { type: 'boolean', short: 'h' },
|
||||
},
|
||||
})
|
||||
|
||||
if (values.help) {
|
||||
console.log(
|
||||
'Usage: bun run build -- --agent <name> --arch <amd64|arm64> --output-dir <path> [--version <override>]',
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!values.agent || !values.arch || !values['output-dir']) {
|
||||
throw new Error('--agent, --arch, and --output-dir are required')
|
||||
}
|
||||
|
||||
const config = await readAgentsConfig(recipePath)
|
||||
const selected = config.agents.find((agent) => agent.name === values.agent)
|
||||
if (!selected) {
|
||||
throw new Error(`unknown agent: ${values.agent}`)
|
||||
}
|
||||
|
||||
const result = await buildTarball({
|
||||
agent: {
|
||||
...selected,
|
||||
version: values.version ?? selected.version,
|
||||
},
|
||||
arch: parseArch(values.arch),
|
||||
outputDir: values['output-dir'],
|
||||
recipePath,
|
||||
})
|
||||
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
import { expandMatrix, readAgentsConfig } from '../src/catalog'
|
||||
|
||||
const packageRoot = resolve(import.meta.dir, '..')
|
||||
const recipePath = resolve(packageRoot, 'recipe', 'agents.json')
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
agent: { type: 'string' },
|
||||
help: { type: 'boolean', short: 'h' },
|
||||
},
|
||||
})
|
||||
|
||||
if (values.help) {
|
||||
console.log('Usage: bun run list-matrix [--agent <name>]')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const config = await readAgentsConfig(recipePath)
|
||||
const include = expandMatrix(config, { agent: values.agent })
|
||||
|
||||
if (include.length === 0) {
|
||||
throw new Error(
|
||||
values.agent
|
||||
? `no agents matched filter: ${values.agent}`
|
||||
: 'recipe/agents.json produced an empty matrix',
|
||||
)
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ include }))
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
import { roundTripPodmanLoad } from '../src/smoke'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
tarball: { type: 'string' },
|
||||
'expected-image': { type: 'string' },
|
||||
'expected-image-id': { type: 'string' },
|
||||
'expected-fingerprint': { type: 'string' },
|
||||
'expected-digest': { type: 'string' },
|
||||
help: { type: 'boolean', short: 'h' },
|
||||
},
|
||||
})
|
||||
|
||||
if (values.help) {
|
||||
console.log(
|
||||
'Usage: bun run smoke -- --tarball <path> --expected-image <ref> [--expected-fingerprint <sha256-hex>] [--expected-image-id <sha256:...> | --expected-digest <sha256:...>]',
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const expectedImageId = values['expected-image-id'] ?? values['expected-digest']
|
||||
if (
|
||||
!values.tarball ||
|
||||
!values['expected-image'] ||
|
||||
(!expectedImageId && !values['expected-fingerprint'])
|
||||
) {
|
||||
throw new Error(
|
||||
'--tarball, --expected-image, and one verification flag are required',
|
||||
)
|
||||
}
|
||||
|
||||
await roundTripPodmanLoad({
|
||||
tarballPath: values.tarball,
|
||||
expectedImage: values['expected-image'],
|
||||
expectedImageId,
|
||||
expectedSmokeFingerprint: values['expected-fingerprint'],
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { readdir } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
import { loadBuildResult } from '../src/build'
|
||||
import { publishAgents } from '../src/publish'
|
||||
|
||||
async function findBuildResultPaths(root: string): Promise<string[]> {
|
||||
const entries = await readdir(root, { withFileTypes: true })
|
||||
const paths: string[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const path = join(root, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
paths.push(...(await findBuildResultPaths(path)))
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name === 'build-result.json') {
|
||||
paths.push(path)
|
||||
}
|
||||
}
|
||||
|
||||
return paths.sort()
|
||||
}
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
'artifact-dir': { type: 'string' },
|
||||
'update-aggregate': { type: 'boolean' },
|
||||
help: { type: 'boolean', short: 'h' },
|
||||
},
|
||||
})
|
||||
|
||||
if (values.help) {
|
||||
console.log(
|
||||
'Usage: bun run upload -- --artifact-dir <path> [--update-aggregate]',
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!values['artifact-dir']) {
|
||||
throw new Error('--artifact-dir is required')
|
||||
}
|
||||
|
||||
const artifactDir = resolve(values['artifact-dir'])
|
||||
const buildResultPaths = await findBuildResultPaths(artifactDir)
|
||||
if (buildResultPaths.length === 0) {
|
||||
throw new Error(`no build-result.json files found under ${artifactDir}`)
|
||||
}
|
||||
|
||||
const buildResults = await Promise.all(
|
||||
buildResultPaths.map((path) => loadBuildResult(path)),
|
||||
)
|
||||
|
||||
await publishAgents({
|
||||
buildResults,
|
||||
updateAggregate: Boolean(values['update-aggregate']),
|
||||
})
|
||||
470
packages/browseros-agent/packages/agent-container/src/build.ts
Normal file
470
packages/browseros-agent/packages/agent-container/src/build.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { access, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
|
||||
import { type AgentEntry, publishNameForAgent } from './catalog'
|
||||
import type { ContainerArch } from './schema/arch'
|
||||
|
||||
const PODMAN_BIN = process.env.PODMAN_BIN ?? 'podman'
|
||||
|
||||
interface PodmanCommandResult {
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
interface PodmanInspectShape {
|
||||
Id?: string
|
||||
Digest?: string
|
||||
RepoDigests?: string[]
|
||||
Architecture?: string
|
||||
Os?: string
|
||||
Config?: unknown
|
||||
RootFS?: unknown
|
||||
}
|
||||
|
||||
interface PodmanImageMetadata {
|
||||
imageId: string
|
||||
sourceOciDigest: string
|
||||
smokeFingerprint: string
|
||||
}
|
||||
|
||||
interface RepoDigestCount {
|
||||
digest: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface BuildOptions {
|
||||
agent: AgentEntry
|
||||
arch: ContainerArch
|
||||
outputDir: string
|
||||
recipePath?: string
|
||||
builtBy?: string
|
||||
}
|
||||
|
||||
export interface BuildResult {
|
||||
name: string
|
||||
publishAs: string
|
||||
image: string
|
||||
version: string
|
||||
arch: ContainerArch
|
||||
sourceOciDigest: string
|
||||
imageId: string
|
||||
smokeFingerprint: string
|
||||
filename: string
|
||||
tarballPath: string
|
||||
tarballShaPath: string
|
||||
compressedSha256: string
|
||||
compressedSizeBytes: number
|
||||
uncompressedSha256: string
|
||||
uncompressedSizeBytes: number
|
||||
podmanVersion: string
|
||||
builtAt: string
|
||||
builtBy: string
|
||||
gitSha: string
|
||||
gitDirty: boolean
|
||||
configSha256: string
|
||||
}
|
||||
|
||||
function stableJson(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((entry) => stableJson(entry)).join(',')}]`
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const entries = Object.entries(value as Record<string, unknown>).sort(
|
||||
([left], [right]) => left.localeCompare(right),
|
||||
)
|
||||
return `{${entries
|
||||
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
|
||||
.join(',')}}`
|
||||
}
|
||||
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function smokeFingerprintForInspect(inspected: PodmanInspectShape): string {
|
||||
const payload = stableJson({
|
||||
Architecture: inspected.Architecture ?? '',
|
||||
Os: inspected.Os ?? '',
|
||||
Config: inspected.Config ?? null,
|
||||
RootFS: inspected.RootFS ?? null,
|
||||
})
|
||||
return createHash('sha256').update(payload).digest('hex')
|
||||
}
|
||||
|
||||
function normalizeSha256Like(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (/^sha256:[a-f0-9]{64}$/.test(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
if (/^[a-f0-9]{64}$/.test(trimmed)) {
|
||||
return `sha256:${trimmed}`
|
||||
}
|
||||
|
||||
throw new Error(`unexpected sha256-like value: ${value}`)
|
||||
}
|
||||
|
||||
function selectSourceOciDigest(
|
||||
platformDigest: string,
|
||||
repoDigests: string[],
|
||||
): string {
|
||||
const counts = new Map<string, number>()
|
||||
for (const digest of repoDigests) {
|
||||
counts.set(digest, (counts.get(digest) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const candidates: RepoDigestCount[] = [...counts.entries()]
|
||||
.filter(([digest]) => digest !== platformDigest)
|
||||
.map(([digest, count]) => ({ digest, count }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.count - left.count || left.digest.localeCompare(right.digest),
|
||||
)
|
||||
|
||||
const [firstCandidate, secondCandidate] = candidates
|
||||
if (!firstCandidate) {
|
||||
return platformDigest
|
||||
}
|
||||
if (!secondCandidate || firstCandidate.count > secondCandidate.count) {
|
||||
return firstCandidate.digest
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`ambiguous source OCI digest for ${platformDigest}: ${candidates
|
||||
.map((candidate) => `${candidate.digest} (${candidate.count})`)
|
||||
.join(', ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
async function runPodman(
|
||||
args: string[],
|
||||
options: { stdin?: string } = {},
|
||||
): Promise<PodmanCommandResult> {
|
||||
const proc = Bun.spawn([PODMAN_BIN, ...args], {
|
||||
stdin: options.stdin ? Buffer.from(`${options.stdin}\n`) : undefined,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
])
|
||||
const exitCode = await proc.exited
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(
|
||||
`podman ${args.join(' ')} exited ${exitCode}\n${stderr.trim() || stdout.trim()}`,
|
||||
)
|
||||
}
|
||||
|
||||
return { stdout, stderr }
|
||||
}
|
||||
|
||||
async function runCommand(command: string[]): Promise<string> {
|
||||
const proc = Bun.spawn(command, {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
])
|
||||
const exitCode = await proc.exited
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(
|
||||
`${command.join(' ')} exited ${exitCode}\n${stderr.trim() || stdout.trim()}`,
|
||||
)
|
||||
}
|
||||
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
async function sha256OfFile(path: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
const stream = createReadStream(path)
|
||||
|
||||
for await (const chunk of stream) {
|
||||
hash.update(chunk)
|
||||
}
|
||||
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
async function gzipArchive(tarPath: string): Promise<void> {
|
||||
const proc = Bun.spawn(['gzip', '-9', '-f', '-k', tarPath], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const exitCode = await proc.exited
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`gzip exited ${exitCode}\n${stderr.trim()}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function gitSha(): Promise<string> {
|
||||
return runCommand(['git', 'rev-parse', 'HEAD'])
|
||||
}
|
||||
|
||||
async function gitDirty(): Promise<boolean> {
|
||||
const stdout = await runCommand(['git', 'status', '--short'])
|
||||
return stdout.length > 0
|
||||
}
|
||||
|
||||
function recipePathForPackage(): string {
|
||||
return resolve(import.meta.dir, '..', 'recipe', 'agents.json')
|
||||
}
|
||||
|
||||
function imageRefForBuild(options: BuildOptions): string {
|
||||
return `${options.agent.image}:${options.agent.version}`
|
||||
}
|
||||
|
||||
function builtByForBuild(explicitBuiltBy?: string): string {
|
||||
if (explicitBuiltBy) {
|
||||
return explicitBuiltBy
|
||||
}
|
||||
|
||||
const workflowRef = process.env.GITHUB_WORKFLOW_REF?.trim()
|
||||
if (workflowRef) {
|
||||
return workflowRef
|
||||
}
|
||||
|
||||
const workflow = process.env.GITHUB_WORKFLOW?.trim()
|
||||
const ref = process.env.GITHUB_REF?.trim()
|
||||
if (workflow && ref) {
|
||||
return `${workflow}@${ref}`
|
||||
}
|
||||
|
||||
const user = process.env.USER ?? process.env.LOGNAME ?? 'unknown'
|
||||
return `local:${user}`
|
||||
}
|
||||
|
||||
export function registryForImage(image: string): string {
|
||||
const firstSegment = image.split('/')[0]
|
||||
if (
|
||||
!firstSegment ||
|
||||
(!firstSegment.includes('.') &&
|
||||
!firstSegment.includes(':') &&
|
||||
firstSegment !== 'localhost')
|
||||
) {
|
||||
return 'docker.io'
|
||||
}
|
||||
|
||||
return firstSegment
|
||||
}
|
||||
|
||||
async function podmanVersion(): Promise<string> {
|
||||
const { stdout } = await runPodman(['--version'])
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
async function podmanLogin(options: {
|
||||
registry: string
|
||||
username: string
|
||||
password: string
|
||||
}): Promise<void> {
|
||||
await runPodman(
|
||||
[
|
||||
'login',
|
||||
'--username',
|
||||
options.username,
|
||||
'--password-stdin',
|
||||
options.registry,
|
||||
],
|
||||
{ stdin: options.password },
|
||||
)
|
||||
}
|
||||
|
||||
async function podmanPull(
|
||||
imageRef: string,
|
||||
arch: ContainerArch,
|
||||
): Promise<void> {
|
||||
await runPodman([
|
||||
'pull',
|
||||
'--quiet',
|
||||
'--os',
|
||||
'linux',
|
||||
'--arch',
|
||||
arch,
|
||||
imageRef,
|
||||
])
|
||||
}
|
||||
|
||||
export async function podmanInspectImage(
|
||||
imageRef: string,
|
||||
): Promise<PodmanImageMetadata> {
|
||||
const { stdout } = await runPodman([
|
||||
'inspect',
|
||||
'--type',
|
||||
'image',
|
||||
'--format',
|
||||
'{{json .}}',
|
||||
imageRef,
|
||||
])
|
||||
const inspected = JSON.parse(stdout.trim()) as PodmanInspectShape
|
||||
const imageId = normalizeSha256Like(inspected.Id ?? '')
|
||||
const platformDigest = normalizeSha256Like(inspected.Digest ?? imageId)
|
||||
const repoDigests = (inspected.RepoDigests ?? [])
|
||||
.map((entry) => entry.split('@')[1] ?? '')
|
||||
.filter(Boolean)
|
||||
.map((entry) => normalizeSha256Like(entry))
|
||||
const sourceOciDigest = selectSourceOciDigest(platformDigest, repoDigests)
|
||||
|
||||
return {
|
||||
imageId,
|
||||
sourceOciDigest,
|
||||
smokeFingerprint: smokeFingerprintForInspect(inspected),
|
||||
}
|
||||
}
|
||||
|
||||
async function podmanSaveOci(options: {
|
||||
imageRef: string
|
||||
outPath: string
|
||||
}): Promise<void> {
|
||||
await runPodman([
|
||||
'save',
|
||||
'--format',
|
||||
'oci-archive',
|
||||
'--output',
|
||||
options.outPath,
|
||||
options.imageRef,
|
||||
])
|
||||
}
|
||||
|
||||
export async function podmanLoadArchive(tarballPath: string): Promise<void> {
|
||||
await runPodman(['load', '--input', tarballPath])
|
||||
}
|
||||
|
||||
export async function podmanRemoveImage(imageRef: string): Promise<void> {
|
||||
await runPodman(['rmi', '-f', imageRef])
|
||||
}
|
||||
|
||||
async function maybeLoginForAgent(options: BuildOptions): Promise<void> {
|
||||
const auth = options.agent.requires_auth
|
||||
if (!auth) {
|
||||
return
|
||||
}
|
||||
|
||||
const password = process.env[auth.secret]?.trim()
|
||||
if (!password) {
|
||||
throw new Error(`missing registry credential env var: ${auth.secret}`)
|
||||
}
|
||||
|
||||
await podmanLogin({
|
||||
registry: registryForImage(options.agent.image),
|
||||
username: auth.username ?? 'oauth2accesstoken',
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
export async function buildTarball(
|
||||
options: BuildOptions,
|
||||
): Promise<BuildResult> {
|
||||
const imageRef = imageRefForBuild(options)
|
||||
const publishAs = publishNameForAgent(options.agent)
|
||||
const outputDir = resolve(options.outputDir)
|
||||
const recipePath = resolve(options.recipePath ?? recipePathForPackage())
|
||||
const baseName = `${publishAs}-${options.agent.version}-${options.arch}.tar`
|
||||
const tarPath = join(outputDir, baseName)
|
||||
const tarballPath = `${tarPath}.gz`
|
||||
const tarballShaPath = `${tarballPath}.sha256`
|
||||
const buildResultPath = join(outputDir, 'build-result.json')
|
||||
|
||||
await mkdir(outputDir, { recursive: true })
|
||||
await Promise.all([
|
||||
rm(tarPath, { force: true }),
|
||||
rm(tarballPath, { force: true }),
|
||||
rm(tarballShaPath, { force: true }),
|
||||
rm(buildResultPath, { force: true }),
|
||||
])
|
||||
|
||||
const [gitShaValue, gitDirtyValue, configSha256, podmanVersionValue] =
|
||||
await Promise.all([
|
||||
gitSha(),
|
||||
gitDirty(),
|
||||
sha256OfFile(recipePath),
|
||||
podmanVersion(),
|
||||
])
|
||||
const builtAt = new Date().toISOString()
|
||||
const builtBy = builtByForBuild(options.builtBy)
|
||||
|
||||
await maybeLoginForAgent(options)
|
||||
await podmanPull(imageRef, options.arch)
|
||||
const inspection = await podmanInspectImage(imageRef)
|
||||
await podmanSaveOci({ imageRef, outPath: tarPath })
|
||||
await gzipArchive(tarPath)
|
||||
|
||||
const [
|
||||
compressedSha256,
|
||||
uncompressedSha256,
|
||||
compressedStats,
|
||||
uncompressedStats,
|
||||
] = await Promise.all([
|
||||
sha256OfFile(tarballPath),
|
||||
sha256OfFile(tarPath),
|
||||
stat(tarballPath),
|
||||
stat(tarPath),
|
||||
])
|
||||
|
||||
const filename = basename(tarballPath)
|
||||
await writeFile(tarballShaPath, `${compressedSha256} ${filename}\n`, 'utf8')
|
||||
await rm(tarPath, { force: true })
|
||||
|
||||
const result: BuildResult = {
|
||||
name: options.agent.name,
|
||||
publishAs,
|
||||
image: options.agent.image,
|
||||
version: options.agent.version,
|
||||
arch: options.arch,
|
||||
sourceOciDigest: inspection.sourceOciDigest,
|
||||
imageId: inspection.imageId,
|
||||
smokeFingerprint: inspection.smokeFingerprint,
|
||||
filename,
|
||||
tarballPath,
|
||||
tarballShaPath,
|
||||
compressedSha256,
|
||||
compressedSizeBytes: compressedStats.size,
|
||||
uncompressedSha256,
|
||||
uncompressedSizeBytes: uncompressedStats.size,
|
||||
podmanVersion: podmanVersionValue,
|
||||
builtAt,
|
||||
builtBy,
|
||||
gitSha: gitShaValue,
|
||||
gitDirty: gitDirtyValue,
|
||||
configSha256,
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
buildResultPath,
|
||||
`${JSON.stringify(result, null, 2)}\n`,
|
||||
'utf8',
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
export async function loadBuildResult(path: string): Promise<BuildResult> {
|
||||
const raw = await readFile(path, 'utf8')
|
||||
const result = JSON.parse(raw) as BuildResult
|
||||
const resultDir = dirname(path)
|
||||
const tarballPath = (await pathExists(result.tarballPath))
|
||||
? result.tarballPath
|
||||
: join(resultDir, result.filename)
|
||||
const tarballShaPath = (await pathExists(result.tarballShaPath))
|
||||
? result.tarballShaPath
|
||||
: `${tarballPath}.sha256`
|
||||
|
||||
return {
|
||||
...result,
|
||||
tarballPath,
|
||||
tarballShaPath,
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { ARCHES, type ContainerArch } from './schema/arch'
|
||||
|
||||
export const agentEntrySchema = z.object({
|
||||
name: z.string().regex(/^[a-z0-9-]+$/),
|
||||
image: z.string().min(1),
|
||||
version: z.string().min(1),
|
||||
arches: z.array(z.enum(ARCHES)).min(1),
|
||||
publishAs: z
|
||||
.string()
|
||||
.regex(/^[a-z0-9-]+$/)
|
||||
.optional(),
|
||||
requires_auth: z
|
||||
.object({
|
||||
secret: z.string().min(1),
|
||||
username: z.string().min(1).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const agentsConfigSchema = z
|
||||
.object({
|
||||
schema: z.literal('v1'),
|
||||
agents: z.array(agentEntrySchema).min(1),
|
||||
})
|
||||
.superRefine((config, ctx) => {
|
||||
const seen = new Set<string>()
|
||||
for (const [index, agent] of config.agents.entries()) {
|
||||
if (seen.has(agent.name)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['agents', index, 'name'],
|
||||
message: `duplicate agent name: ${agent.name}`,
|
||||
})
|
||||
}
|
||||
seen.add(agent.name)
|
||||
}
|
||||
})
|
||||
|
||||
export type AgentEntry = z.infer<typeof agentEntrySchema>
|
||||
export type AgentsConfig = z.infer<typeof agentsConfigSchema>
|
||||
|
||||
export interface MatrixEntry {
|
||||
agent: string
|
||||
image: string
|
||||
version: string
|
||||
arch: ContainerArch
|
||||
publishAs: string
|
||||
}
|
||||
|
||||
export function publishNameForAgent(agent: AgentEntry): string {
|
||||
return agent.publishAs ?? agent.name
|
||||
}
|
||||
|
||||
export async function readAgentsConfig(path: string): Promise<AgentsConfig> {
|
||||
const raw = await readFile(path, 'utf8')
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return agentsConfigSchema.parse(parsed)
|
||||
}
|
||||
|
||||
export function expandMatrix(
|
||||
config: AgentsConfig,
|
||||
filter: { agent?: string } = {},
|
||||
): MatrixEntry[] {
|
||||
const entries: MatrixEntry[] = []
|
||||
|
||||
for (const agent of config.agents) {
|
||||
if (filter.agent && agent.name !== filter.agent) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const arch of agent.arches) {
|
||||
entries.push({
|
||||
agent: agent.name,
|
||||
image: agent.image,
|
||||
version: agent.version,
|
||||
arch,
|
||||
publishAs: publishNameForAgent(agent),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return entries.sort((left, right) => {
|
||||
const byAgent = left.agent.localeCompare(right.agent)
|
||||
if (byAgent !== 0) {
|
||||
return byAgent
|
||||
}
|
||||
|
||||
return left.arch.localeCompare(right.arch)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type {
|
||||
AgentArtifact,
|
||||
AgentManifest,
|
||||
AggregateManifest,
|
||||
ContainerArch,
|
||||
} from './schema'
|
||||
|
||||
export async function fetchAggregateManifest(): Promise<AggregateManifest> {
|
||||
throw new Error('fetchAggregateManifest: implemented in WS6')
|
||||
}
|
||||
|
||||
export async function fetchAgentManifest(
|
||||
_agent: string,
|
||||
_version: string,
|
||||
): Promise<AgentManifest> {
|
||||
throw new Error('fetchAgentManifest: implemented in WS6')
|
||||
}
|
||||
|
||||
export async function verifySha256(
|
||||
_path: string,
|
||||
_expectedSha256: string,
|
||||
): Promise<void> {
|
||||
throw new Error('verifySha256: implemented in WS6')
|
||||
}
|
||||
|
||||
export async function findStagedTarball(
|
||||
_name: string,
|
||||
_version: string,
|
||||
_arch: ContainerArch,
|
||||
): Promise<string> {
|
||||
throw new Error('findStagedTarball: implemented in WS6')
|
||||
}
|
||||
|
||||
export async function loadTarball(
|
||||
_artifact: AgentArtifact,
|
||||
_destinationPath: string,
|
||||
): Promise<void> {
|
||||
throw new Error('loadTarball: implemented in WS6')
|
||||
}
|
||||
439
packages/browseros-agent/packages/agent-container/src/publish.ts
Normal file
439
packages/browseros-agent/packages/agent-container/src/publish.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
type S3ClientConfig,
|
||||
} from '@aws-sdk/client-s3'
|
||||
|
||||
import type { BuildResult } from './build'
|
||||
import { ARCHES } from './schema/arch'
|
||||
import {
|
||||
type AgentManifest,
|
||||
type AggregateEntry,
|
||||
type AggregateManifest,
|
||||
agentManifestSchema,
|
||||
aggregateManifestSchema,
|
||||
} from './schema/manifest'
|
||||
import {
|
||||
keyForAggregateManifest,
|
||||
keyForSha,
|
||||
keyForTarball,
|
||||
keyForVersionManifest,
|
||||
} from './schema/r2-keys'
|
||||
|
||||
const CDN_BASE_URL =
|
||||
process.env.R2_PUBLIC_BASE_URL ?? 'https://cdn.browseros.com'
|
||||
const JSON_CONTENT_TYPE = 'application/json; charset=utf-8'
|
||||
const SHA_CONTENT_TYPE = 'text/plain; charset=utf-8'
|
||||
|
||||
export interface PublishOptions {
|
||||
buildResults: BuildResult[]
|
||||
updateAggregate: boolean
|
||||
bucket?: string
|
||||
cdnBaseURL?: string
|
||||
client?: S3Client
|
||||
now?: () => Date
|
||||
}
|
||||
|
||||
interface ResultGroup {
|
||||
name: string
|
||||
publishAs: string
|
||||
image: string
|
||||
version: string
|
||||
sourceOciDigest: string
|
||||
podmanVersions: string[]
|
||||
gitSha: string
|
||||
gitDirty: boolean
|
||||
configSha256: string
|
||||
builtBy: string
|
||||
results: BuildResult[]
|
||||
}
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) {
|
||||
throw new Error(`missing required env var: ${name}`)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function createR2Client(): S3Client {
|
||||
const config: S3ClientConfig = {
|
||||
region: 'auto',
|
||||
endpoint: `https://${requiredEnv('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: requiredEnv('R2_ACCESS_KEY_ID'),
|
||||
secretAccessKey: requiredEnv('R2_SECRET_ACCESS_KEY'),
|
||||
},
|
||||
}
|
||||
|
||||
return new S3Client(config)
|
||||
}
|
||||
|
||||
function getBucket(): string {
|
||||
return requiredEnv('R2_BUCKET')
|
||||
}
|
||||
|
||||
async function uploadFile(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
path: string,
|
||||
contentType = 'application/gzip',
|
||||
): Promise<void> {
|
||||
const { size } = await stat(path)
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: createReadStream(path),
|
||||
ContentLength: size,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function uploadBody(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
body: string | Uint8Array,
|
||||
contentType = JSON_CONTENT_TYPE,
|
||||
): Promise<void> {
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteObject(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
): Promise<void> {
|
||||
await client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function keyForGroup(name: string, version: string): string {
|
||||
return `${name}:${version}`
|
||||
}
|
||||
|
||||
function compareByArch(left: BuildResult, right: BuildResult): number {
|
||||
return ARCHES.indexOf(left.arch) - ARCHES.indexOf(right.arch)
|
||||
}
|
||||
|
||||
function cdnUrl(baseUrl: string, key: string): string {
|
||||
return `${baseUrl.replace(/\/+$/, '')}/${key}`
|
||||
}
|
||||
|
||||
function createManifestForGroup(
|
||||
group: ResultGroup,
|
||||
builtAt: string,
|
||||
cdnBaseURL: string,
|
||||
): AgentManifest {
|
||||
return agentManifestSchema.parse({
|
||||
name: group.name,
|
||||
schema: 'v1',
|
||||
build: {
|
||||
git_sha: group.gitSha,
|
||||
git_dirty: group.gitDirty,
|
||||
built_at: builtAt,
|
||||
built_by: group.builtBy,
|
||||
config_sha256: group.configSha256,
|
||||
podman_versions: group.podmanVersions,
|
||||
},
|
||||
source: {
|
||||
image: group.image,
|
||||
version: group.version,
|
||||
oci_digest: group.sourceOciDigest,
|
||||
},
|
||||
artifacts: [...group.results].sort(compareByArch).map((result) => {
|
||||
const key = keyForTarball(
|
||||
result.name,
|
||||
result.version,
|
||||
result.arch,
|
||||
result.publishAs,
|
||||
)
|
||||
return {
|
||||
arch: result.arch,
|
||||
filename: result.filename,
|
||||
format: 'oci-archive+gzip',
|
||||
compressed_sha256: result.compressedSha256,
|
||||
compressed_size_bytes: result.compressedSizeBytes,
|
||||
uncompressed_sha256: result.uncompressedSha256,
|
||||
uncompressed_size_bytes: result.uncompressedSizeBytes,
|
||||
url: cdnUrl(cdnBaseURL, key),
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function mergeAggregateEntries(
|
||||
existing: AggregateEntry[],
|
||||
nextEntries: AggregateEntry[],
|
||||
builtAt: string,
|
||||
builtBy: string,
|
||||
): AggregateManifest {
|
||||
const merged = new Map<string, AggregateEntry>()
|
||||
|
||||
for (const entry of existing) {
|
||||
merged.set(entry.name, entry)
|
||||
}
|
||||
for (const entry of nextEntries) {
|
||||
merged.set(entry.name, entry)
|
||||
}
|
||||
|
||||
return aggregateManifestSchema.parse({
|
||||
schema: 'v1',
|
||||
built_at: builtAt,
|
||||
built_by: builtBy,
|
||||
agents: [...merged.values()].sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
function buildAggregateEntries(
|
||||
groups: ResultGroup[],
|
||||
cdnBaseURL: string,
|
||||
): AggregateEntry[] {
|
||||
return groups
|
||||
.map((group) => ({
|
||||
name: group.name,
|
||||
version: group.version,
|
||||
oci_digest: group.sourceOciDigest,
|
||||
manifest_url: cdnUrl(
|
||||
cdnBaseURL,
|
||||
keyForVersionManifest(group.name, group.version),
|
||||
),
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
function aggregateBuiltBy(groups: ResultGroup[]): string {
|
||||
const builtByValues = [
|
||||
...new Set(groups.map((group) => group.builtBy)),
|
||||
].sort()
|
||||
return builtByValues.join(', ')
|
||||
}
|
||||
|
||||
function buildGroup(results: BuildResult[]): ResultGroup {
|
||||
const [firstResult, ...rest] = results
|
||||
if (!firstResult) {
|
||||
throw new Error('cannot publish an empty build result group')
|
||||
}
|
||||
|
||||
for (const result of rest) {
|
||||
if (result.name !== firstResult.name) {
|
||||
throw new Error('mixed agent names in publish group')
|
||||
}
|
||||
if (result.publishAs !== firstResult.publishAs) {
|
||||
throw new Error('mixed publishAs values in publish group')
|
||||
}
|
||||
if (result.image !== firstResult.image) {
|
||||
throw new Error('mixed source images in publish group')
|
||||
}
|
||||
if (result.version !== firstResult.version) {
|
||||
throw new Error('mixed versions in publish group')
|
||||
}
|
||||
if (result.sourceOciDigest !== firstResult.sourceOciDigest) {
|
||||
throw new Error('mixed source OCI digests in publish group')
|
||||
}
|
||||
if (
|
||||
result.gitSha !== firstResult.gitSha ||
|
||||
result.gitDirty !== firstResult.gitDirty
|
||||
) {
|
||||
throw new Error('mixed git metadata in publish group')
|
||||
}
|
||||
if (result.configSha256 !== firstResult.configSha256) {
|
||||
throw new Error('mixed recipe config hashes in publish group')
|
||||
}
|
||||
if (result.builtBy !== firstResult.builtBy) {
|
||||
throw new Error('mixed build provenance in publish group')
|
||||
}
|
||||
}
|
||||
|
||||
const podmanVersions = [
|
||||
...new Set(results.map((result) => result.podmanVersion)),
|
||||
].sort()
|
||||
|
||||
return {
|
||||
name: firstResult.name,
|
||||
publishAs: firstResult.publishAs,
|
||||
image: firstResult.image,
|
||||
version: firstResult.version,
|
||||
sourceOciDigest: firstResult.sourceOciDigest,
|
||||
podmanVersions,
|
||||
gitSha: firstResult.gitSha,
|
||||
gitDirty: firstResult.gitDirty,
|
||||
configSha256: firstResult.configSha256,
|
||||
builtBy: firstResult.builtBy,
|
||||
results: [...results].sort(compareByArch),
|
||||
}
|
||||
}
|
||||
|
||||
function groupByAgentVersion(buildResults: BuildResult[]): ResultGroup[] {
|
||||
const grouped = new Map<string, BuildResult[]>()
|
||||
|
||||
for (const result of buildResults) {
|
||||
const key = keyForGroup(result.name, result.version)
|
||||
const existing = grouped.get(key)
|
||||
if (existing) {
|
||||
existing.push(result)
|
||||
continue
|
||||
}
|
||||
grouped.set(key, [result])
|
||||
}
|
||||
|
||||
return [...grouped.values()]
|
||||
.map((results) => buildGroup(results))
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
async function readBodyAsString(body: unknown): Promise<string> {
|
||||
const withTransform = body as {
|
||||
transformToByteArray?: () => Promise<Uint8Array>
|
||||
}
|
||||
if (!withTransform?.transformToByteArray) {
|
||||
throw new Error('R2 response body is not readable')
|
||||
}
|
||||
|
||||
const bytes = await withTransform.transformToByteArray()
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
async function readExistingAggregateEntries(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
): Promise<AggregateEntry[]> {
|
||||
try {
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: keyForAggregateManifest(),
|
||||
}),
|
||||
)
|
||||
const body = await readBodyAsString(response.Body)
|
||||
const parsed = aggregateManifestSchema.parse(JSON.parse(body))
|
||||
return parsed.agents
|
||||
} catch (error) {
|
||||
const maybeError = error as {
|
||||
name?: string
|
||||
$metadata?: { httpStatusCode?: number }
|
||||
}
|
||||
if (
|
||||
maybeError?.name === 'NoSuchKey' ||
|
||||
maybeError?.$metadata?.httpStatusCode === 404
|
||||
) {
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackKeys(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
uploadedKeys: string[],
|
||||
): Promise<void> {
|
||||
await Promise.allSettled(
|
||||
[...uploadedKeys].reverse().map((key) => deleteObject(client, bucket, key)),
|
||||
)
|
||||
}
|
||||
|
||||
export async function publishAgents(options: PublishOptions): Promise<void> {
|
||||
if (options.buildResults.length === 0) {
|
||||
throw new Error('buildResults must not be empty')
|
||||
}
|
||||
|
||||
const client = options.client ?? createR2Client()
|
||||
const bucket = options.bucket ?? getBucket()
|
||||
const cdnBaseURL = options.cdnBaseURL ?? CDN_BASE_URL
|
||||
const now = options.now ?? (() => new Date())
|
||||
const uploadedKeys: string[] = []
|
||||
|
||||
try {
|
||||
const groups = groupByAgentVersion(options.buildResults)
|
||||
const builtAt = now().toISOString()
|
||||
|
||||
for (const group of groups) {
|
||||
for (const result of group.results) {
|
||||
const tarKey = keyForTarball(
|
||||
result.name,
|
||||
result.version,
|
||||
result.arch,
|
||||
result.publishAs,
|
||||
)
|
||||
const shaKey = keyForSha(
|
||||
result.name,
|
||||
result.version,
|
||||
result.arch,
|
||||
result.publishAs,
|
||||
)
|
||||
|
||||
await uploadFile(client, bucket, tarKey, result.tarballPath)
|
||||
uploadedKeys.push(tarKey)
|
||||
|
||||
await uploadFile(
|
||||
client,
|
||||
bucket,
|
||||
shaKey,
|
||||
result.tarballShaPath,
|
||||
SHA_CONTENT_TYPE,
|
||||
)
|
||||
uploadedKeys.push(shaKey)
|
||||
}
|
||||
|
||||
const manifest = createManifestForGroup(group, builtAt, cdnBaseURL)
|
||||
const manifestKey = keyForVersionManifest(group.name, group.version)
|
||||
await uploadBody(
|
||||
client,
|
||||
bucket,
|
||||
manifestKey,
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
JSON_CONTENT_TYPE,
|
||||
)
|
||||
uploadedKeys.push(manifestKey)
|
||||
}
|
||||
|
||||
if (options.updateAggregate) {
|
||||
const existingEntries = await readExistingAggregateEntries(client, bucket)
|
||||
const aggregate = mergeAggregateEntries(
|
||||
existingEntries,
|
||||
buildAggregateEntries(groups, cdnBaseURL),
|
||||
builtAt,
|
||||
aggregateBuiltBy(groups),
|
||||
)
|
||||
const aggregateKey = keyForAggregateManifest()
|
||||
await uploadBody(
|
||||
client,
|
||||
bucket,
|
||||
aggregateKey,
|
||||
`${JSON.stringify(aggregate, null, 2)}\n`,
|
||||
JSON_CONTENT_TYPE,
|
||||
)
|
||||
uploadedKeys.push(aggregateKey)
|
||||
}
|
||||
} catch (error) {
|
||||
await rollbackKeys(client, bucket, uploadedKeys)
|
||||
throw error
|
||||
} finally {
|
||||
if (!options.client) {
|
||||
client.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const ARCHES = ['amd64', 'arm64'] as const
|
||||
|
||||
export type ContainerArch = (typeof ARCHES)[number]
|
||||
|
||||
export function parseArch(value: string): ContainerArch {
|
||||
if (value === 'amd64' || value === 'arm64') {
|
||||
return value
|
||||
}
|
||||
|
||||
throw new Error(`invalid container arch: ${value}`)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type { ContainerArch } from './arch'
|
||||
export { ARCHES, parseArch } from './arch'
|
||||
export type {
|
||||
AgentArtifact,
|
||||
AgentManifest,
|
||||
AggregateEntry,
|
||||
AggregateManifest,
|
||||
} from './manifest'
|
||||
export {
|
||||
agentArtifactSchema,
|
||||
agentManifestSchema,
|
||||
aggregateEntrySchema,
|
||||
aggregateManifestSchema,
|
||||
MANIFEST_SCHEMA_VERSION,
|
||||
ociDigestSchema,
|
||||
parseAgentManifest,
|
||||
parseAggregateManifest,
|
||||
sha256HexSchema,
|
||||
} from './manifest'
|
||||
export {
|
||||
keyForAggregateManifest,
|
||||
keyForSha,
|
||||
keyForTarball,
|
||||
keyForVersionManifest,
|
||||
R2_AGENTS_PREFIX,
|
||||
} from './r2-keys'
|
||||
@@ -0,0 +1,65 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { ARCHES } from './arch'
|
||||
|
||||
export const MANIFEST_SCHEMA_VERSION = 'v1' as const
|
||||
|
||||
export const sha256HexSchema = z.string().regex(/^[a-f0-9]{64}$/)
|
||||
export const ociDigestSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/)
|
||||
|
||||
export const agentArtifactSchema = z.object({
|
||||
arch: z.enum(ARCHES),
|
||||
filename: z.string().min(1),
|
||||
format: z.literal('oci-archive+gzip'),
|
||||
compressed_sha256: sha256HexSchema,
|
||||
compressed_size_bytes: z.number().int().positive(),
|
||||
uncompressed_sha256: sha256HexSchema,
|
||||
uncompressed_size_bytes: z.number().int().positive(),
|
||||
url: z.string().url(),
|
||||
})
|
||||
|
||||
export const agentManifestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
schema: z.literal(MANIFEST_SCHEMA_VERSION),
|
||||
build: z.object({
|
||||
git_sha: z.string().min(1),
|
||||
git_dirty: z.boolean(),
|
||||
built_at: z.string().datetime(),
|
||||
built_by: z.string().min(1),
|
||||
config_sha256: sha256HexSchema,
|
||||
podman_versions: z.array(z.string().min(1)).min(1),
|
||||
}),
|
||||
source: z.object({
|
||||
image: z.string().min(1),
|
||||
version: z.string().min(1),
|
||||
oci_digest: ociDigestSchema,
|
||||
}),
|
||||
artifacts: z.array(agentArtifactSchema).min(1),
|
||||
})
|
||||
|
||||
export const aggregateEntrySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
version: z.string().min(1),
|
||||
oci_digest: ociDigestSchema,
|
||||
manifest_url: z.string().url(),
|
||||
})
|
||||
|
||||
export const aggregateManifestSchema = z.object({
|
||||
schema: z.literal(MANIFEST_SCHEMA_VERSION),
|
||||
built_at: z.string().datetime(),
|
||||
built_by: z.string().min(1),
|
||||
agents: z.array(aggregateEntrySchema).min(1),
|
||||
})
|
||||
|
||||
export type AgentArtifact = z.infer<typeof agentArtifactSchema>
|
||||
export type AgentManifest = z.infer<typeof agentManifestSchema>
|
||||
export type AggregateEntry = z.infer<typeof aggregateEntrySchema>
|
||||
export type AggregateManifest = z.infer<typeof aggregateManifestSchema>
|
||||
|
||||
export function parseAgentManifest(raw: unknown): AgentManifest {
|
||||
return agentManifestSchema.parse(raw)
|
||||
}
|
||||
|
||||
export function parseAggregateManifest(raw: unknown): AggregateManifest {
|
||||
return aggregateManifestSchema.parse(raw)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ContainerArch } from './arch'
|
||||
|
||||
export const R2_AGENTS_PREFIX = 'agents'
|
||||
|
||||
export function keyForTarball(
|
||||
agent: string,
|
||||
version: string,
|
||||
arch: ContainerArch,
|
||||
publishAs = agent,
|
||||
): string {
|
||||
return `${R2_AGENTS_PREFIX}/${agent}/${version}/${publishAs}-${version}-${arch}.tar.gz`
|
||||
}
|
||||
|
||||
export function keyForSha(
|
||||
agent: string,
|
||||
version: string,
|
||||
arch: ContainerArch,
|
||||
publishAs = agent,
|
||||
): string {
|
||||
return `${keyForTarball(agent, version, arch, publishAs)}.sha256`
|
||||
}
|
||||
|
||||
export function keyForVersionManifest(agent: string, version: string): string {
|
||||
return `${R2_AGENTS_PREFIX}/${agent}/${version}/manifest.json`
|
||||
}
|
||||
|
||||
export function keyForAggregateManifest(): string {
|
||||
return `${R2_AGENTS_PREFIX}/manifest.json`
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createReadStream, createWriteStream } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { createGunzip } from 'node:zlib'
|
||||
|
||||
import {
|
||||
podmanInspectImage,
|
||||
podmanLoadArchive,
|
||||
podmanRemoveImage,
|
||||
} from './build'
|
||||
|
||||
export interface RoundTripPodmanLoadOptions {
|
||||
tarballPath: string
|
||||
expectedImage: string
|
||||
expectedImageId?: string
|
||||
expectedSmokeFingerprint?: string
|
||||
}
|
||||
|
||||
async function maybeDecompressTarball(tarballPath: string): Promise<{
|
||||
tarPath: string
|
||||
cleanupDir?: string
|
||||
}> {
|
||||
if (!tarballPath.endsWith('.gz')) {
|
||||
return { tarPath: tarballPath }
|
||||
}
|
||||
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'agent-container-smoke-'))
|
||||
const tarPath = join(tempDir, 'image.tar')
|
||||
await pipeline(
|
||||
createReadStream(tarballPath),
|
||||
createGunzip(),
|
||||
createWriteStream(tarPath),
|
||||
)
|
||||
|
||||
return { tarPath, cleanupDir: tempDir }
|
||||
}
|
||||
|
||||
export async function roundTripPodmanLoad(
|
||||
options: RoundTripPodmanLoadOptions,
|
||||
): Promise<void> {
|
||||
if (!options.expectedImageId && !options.expectedSmokeFingerprint) {
|
||||
throw new Error(
|
||||
'expectedImageId or expectedSmokeFingerprint is required for smoke verification',
|
||||
)
|
||||
}
|
||||
|
||||
const decompressed = await maybeDecompressTarball(options.tarballPath)
|
||||
|
||||
try {
|
||||
await podmanRemoveImage(options.expectedImage).catch(() => {})
|
||||
await podmanLoadArchive(decompressed.tarPath)
|
||||
|
||||
const inspection = await podmanInspectImage(options.expectedImage)
|
||||
if (
|
||||
options.expectedSmokeFingerprint &&
|
||||
inspection.smokeFingerprint !== options.expectedSmokeFingerprint
|
||||
) {
|
||||
throw new Error(
|
||||
`loaded image fingerprint mismatch: expected ${options.expectedSmokeFingerprint}, got ${inspection.smokeFingerprint}`,
|
||||
)
|
||||
}
|
||||
if (
|
||||
options.expectedImageId &&
|
||||
inspection.imageId !== options.expectedImageId
|
||||
) {
|
||||
throw new Error(
|
||||
`loaded image ID mismatch: expected ${options.expectedImageId}, got ${inspection.imageId}`,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await podmanRemoveImage(options.expectedImage).catch(() => {})
|
||||
if (decompressed.cleanupDir) {
|
||||
await rm(decompressed.cleanupDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
|
||||
import { ARCHES, parseArch } from '../src/schema/arch'
|
||||
|
||||
describe('schema/arch', () => {
|
||||
it('exports the supported arches', () => {
|
||||
expect(ARCHES).toEqual(['amd64', 'arm64'])
|
||||
})
|
||||
|
||||
it('parses valid arches', () => {
|
||||
expect(parseArch('amd64')).toBe('amd64')
|
||||
expect(parseArch('arm64')).toBe('arm64')
|
||||
})
|
||||
|
||||
it('rejects invalid arches', () => {
|
||||
expect(() => parseArch('x64')).toThrow('invalid container arch')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { expandMatrix, readAgentsConfig } from '../src/catalog'
|
||||
|
||||
const tempPaths: string[] = []
|
||||
|
||||
async function writeTempConfig(contents: unknown): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'agent-container-catalog-'))
|
||||
const filePath = join(dir, 'agents.json')
|
||||
tempPaths.push(dir)
|
||||
await writeFile(filePath, `${JSON.stringify(contents, null, 2)}\n`, 'utf8')
|
||||
return filePath
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempPaths
|
||||
.splice(0)
|
||||
.map((path) => rm(path, { recursive: true, force: true })),
|
||||
)
|
||||
})
|
||||
|
||||
describe('catalog', () => {
|
||||
it('reads and expands the agent matrix', async () => {
|
||||
const path = await writeTempConfig({
|
||||
schema: 'v1',
|
||||
agents: [
|
||||
{
|
||||
name: 'openclaw',
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
arches: ['amd64', 'arm64'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const config = await readAgentsConfig(path)
|
||||
expect(expandMatrix(config)).toEqual([
|
||||
{
|
||||
agent: 'openclaw',
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
arch: 'amd64',
|
||||
publishAs: 'openclaw',
|
||||
},
|
||||
{
|
||||
agent: 'openclaw',
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
arch: 'arm64',
|
||||
publishAs: 'openclaw',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('filters the matrix by agent name', async () => {
|
||||
const path = await writeTempConfig({
|
||||
schema: 'v1',
|
||||
agents: [
|
||||
{
|
||||
name: 'openclaw',
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
arches: ['amd64'],
|
||||
},
|
||||
{
|
||||
name: 'claude-code',
|
||||
image: 'ghcr.io/example/claude-code',
|
||||
version: '1.2.3',
|
||||
arches: ['arm64'],
|
||||
publishAs: 'claude',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const config = await readAgentsConfig(path)
|
||||
expect(expandMatrix(config, { agent: 'claude-code' })).toEqual([
|
||||
{
|
||||
agent: 'claude-code',
|
||||
image: 'ghcr.io/example/claude-code',
|
||||
version: '1.2.3',
|
||||
arch: 'arm64',
|
||||
publishAs: 'claude',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects duplicate agent names', async () => {
|
||||
const path = await writeTempConfig({
|
||||
schema: 'v1',
|
||||
agents: [
|
||||
{
|
||||
name: 'openclaw',
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
arches: ['amd64'],
|
||||
},
|
||||
{
|
||||
name: 'openclaw',
|
||||
image: 'ghcr.io/example/openclaw',
|
||||
version: '2026.4.13',
|
||||
arches: ['arm64'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await expect(readAgentsConfig(path)).rejects.toThrow('duplicate agent name')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import { readAgentsConfig } from '../src/catalog'
|
||||
|
||||
const packageRoot = resolve(import.meta.dir, '..')
|
||||
const recipePath = resolve(packageRoot, 'recipe', 'agents.json')
|
||||
const runtimePath = resolve(
|
||||
import.meta.dir,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'apps',
|
||||
'server',
|
||||
'src',
|
||||
'api',
|
||||
'services',
|
||||
'openclaw',
|
||||
'openclaw-service.ts',
|
||||
)
|
||||
|
||||
describe('OpenClaw drift guard', () => {
|
||||
it('keeps recipe/agents.json in sync with the runtime image pin', async () => {
|
||||
const [config, runtimeSource] = await Promise.all([
|
||||
readAgentsConfig(recipePath),
|
||||
readFile(runtimePath, 'utf8'),
|
||||
])
|
||||
|
||||
const openclaw = config.agents.find((agent) => agent.name === 'openclaw')
|
||||
expect(openclaw).toBeDefined()
|
||||
|
||||
const match = runtimeSource.match(
|
||||
/return process\.env\.OPENCLAW_IMAGE \|\| ['"]([^'"]+)['"]/,
|
||||
)
|
||||
if (!match?.[1]) {
|
||||
throw new Error(
|
||||
`failed to extract OpenClaw image fallback from ${runtimePath}`,
|
||||
)
|
||||
}
|
||||
|
||||
const recipeImage = `${openclaw?.image}:${openclaw?.version}`
|
||||
expect(recipeImage).toBe(match[1], {
|
||||
message: `OpenClaw image drifted between ${recipePath} and ${runtimePath}`,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import {
|
||||
buildTarball,
|
||||
podmanInspectImage,
|
||||
registryForImage,
|
||||
} from '../src/build'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
function processResult(
|
||||
stdout: string,
|
||||
stderr = '',
|
||||
exitCode = 0,
|
||||
): Bun.Subprocess {
|
||||
return {
|
||||
stdout: new Response(stdout).body,
|
||||
stderr: new Response(stderr).body,
|
||||
exited: Promise.resolve(exitCode),
|
||||
} as Bun.Subprocess
|
||||
}
|
||||
|
||||
async function createTempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'agent-container-build-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
await Promise.all(
|
||||
tempDirs
|
||||
.splice(0)
|
||||
.map((path) => rm(path, { recursive: true, force: true })),
|
||||
)
|
||||
})
|
||||
|
||||
describe('build', () => {
|
||||
it('resolves registry hosts correctly', () => {
|
||||
expect(registryForImage('ghcr.io/openclaw/openclaw')).toBe('ghcr.io')
|
||||
expect(registryForImage('localhost:5000/example/image')).toBe(
|
||||
'localhost:5000',
|
||||
)
|
||||
expect(registryForImage('busybox')).toBe('docker.io')
|
||||
})
|
||||
|
||||
it('builds a tarball result and writes the sidecar files', async () => {
|
||||
const dir = await createTempDir()
|
||||
const outputDir = join(dir, 'dist')
|
||||
const recipePath = join(dir, 'agents.json')
|
||||
await writeFile(
|
||||
recipePath,
|
||||
JSON.stringify({
|
||||
schema: 'v1',
|
||||
agents: [
|
||||
{
|
||||
name: 'openclaw',
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
arches: ['arm64'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const originalSpawn = Bun.spawn
|
||||
const podmanCommands: string[][] = []
|
||||
spyOn(Bun, 'spawn').mockImplementation((command, options) => {
|
||||
if (Array.isArray(command) && command[0] === 'podman') {
|
||||
podmanCommands.push(command)
|
||||
|
||||
if (command[1] === '--version') {
|
||||
return processResult('podman version 5.8.1\n')
|
||||
}
|
||||
if (command[1] === 'pull') {
|
||||
return processResult('')
|
||||
}
|
||||
if (command[1] === 'inspect') {
|
||||
return processResult(
|
||||
JSON.stringify({
|
||||
Id: 'f'.repeat(64),
|
||||
Digest: `sha256:${'1'.repeat(64)}`,
|
||||
RepoDigests: [
|
||||
`ghcr.io/openclaw/openclaw@sha256:${'2'.repeat(64)}`,
|
||||
`ghcr.io/openclaw/openclaw@sha256:${'1'.repeat(64)}`,
|
||||
],
|
||||
Architecture: 'arm64',
|
||||
Os: 'linux',
|
||||
Config: {
|
||||
Entrypoint: ['/entrypoint.sh'],
|
||||
Env: ['NODE_ENV=production'],
|
||||
},
|
||||
RootFS: {
|
||||
Type: 'layers',
|
||||
Layers: ['sha256:abc'],
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (command[1] === 'save') {
|
||||
const outPath = String(command[5])
|
||||
void writeFile(outPath, 'oci archive payload', 'utf8')
|
||||
return processResult('')
|
||||
}
|
||||
}
|
||||
|
||||
return originalSpawn(
|
||||
command as string[],
|
||||
options as SpawnOptions.OptionsObject<string[]>,
|
||||
)
|
||||
})
|
||||
|
||||
const result = await buildTarball({
|
||||
agent: {
|
||||
name: 'openclaw',
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
arches: ['arm64'],
|
||||
},
|
||||
arch: 'arm64',
|
||||
outputDir,
|
||||
recipePath,
|
||||
builtBy: 'test-run',
|
||||
})
|
||||
|
||||
expect(result.filename).toBe('openclaw-2026.4.12-arm64.tar.gz')
|
||||
expect(result.sourceOciDigest).toBe(`sha256:${'2'.repeat(64)}`)
|
||||
expect(result.imageId).toBe(`sha256:${'f'.repeat(64)}`)
|
||||
expect(result.smokeFingerprint).toHaveLength(64)
|
||||
expect(existsSync(result.tarballPath)).toBe(true)
|
||||
expect(existsSync(result.tarballShaPath)).toBe(true)
|
||||
expect(existsSync(join(outputDir, 'openclaw-2026.4.12-arm64.tar'))).toBe(
|
||||
false,
|
||||
)
|
||||
expect(existsSync(join(outputDir, 'build-result.json'))).toBe(true)
|
||||
expect(
|
||||
podmanCommands.some(
|
||||
(command) =>
|
||||
command[1] === 'pull' &&
|
||||
command.includes('--arch') &&
|
||||
command.includes('arm64'),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('prefers the repeated non-platform repo digest as the source OCI digest', async () => {
|
||||
const originalSpawn = Bun.spawn
|
||||
spyOn(Bun, 'spawn').mockImplementation((command, options) => {
|
||||
if (
|
||||
Array.isArray(command) &&
|
||||
command[0] === 'podman' &&
|
||||
command[1] === 'inspect'
|
||||
) {
|
||||
return processResult(
|
||||
JSON.stringify({
|
||||
Id: 'f'.repeat(64),
|
||||
Digest: `sha256:${'1'.repeat(64)}`,
|
||||
RepoDigests: [
|
||||
`ghcr.io/openclaw/openclaw@sha256:${'2'.repeat(64)}`,
|
||||
`mirror.example/openclaw/openclaw@sha256:${'2'.repeat(64)}`,
|
||||
`docker.io/openclaw/openclaw@sha256:${'1'.repeat(64)}`,
|
||||
],
|
||||
Architecture: 'arm64',
|
||||
Os: 'linux',
|
||||
Config: {},
|
||||
RootFS: {},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return originalSpawn(
|
||||
command as string[],
|
||||
options as SpawnOptions.OptionsObject<string[]>,
|
||||
)
|
||||
})
|
||||
|
||||
const inspection = await podmanInspectImage(
|
||||
'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
)
|
||||
expect(inspection.sourceOciDigest).toBe(`sha256:${'2'.repeat(64)}`)
|
||||
})
|
||||
|
||||
it('fails when repo digests disagree without a clear winner', async () => {
|
||||
const originalSpawn = Bun.spawn
|
||||
spyOn(Bun, 'spawn').mockImplementation((command, options) => {
|
||||
if (
|
||||
Array.isArray(command) &&
|
||||
command[0] === 'podman' &&
|
||||
command[1] === 'inspect'
|
||||
) {
|
||||
return processResult(
|
||||
JSON.stringify({
|
||||
Id: 'f'.repeat(64),
|
||||
Digest: `sha256:${'1'.repeat(64)}`,
|
||||
RepoDigests: [
|
||||
`ghcr.io/openclaw/openclaw@sha256:${'2'.repeat(64)}`,
|
||||
`mirror.example/openclaw/openclaw@sha256:${'3'.repeat(64)}`,
|
||||
`docker.io/openclaw/openclaw@sha256:${'1'.repeat(64)}`,
|
||||
],
|
||||
Architecture: 'arm64',
|
||||
Os: 'linux',
|
||||
Config: {},
|
||||
RootFS: {},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return originalSpawn(
|
||||
command as string[],
|
||||
options as SpawnOptions.OptionsObject<string[]>,
|
||||
)
|
||||
})
|
||||
|
||||
await expect(
|
||||
podmanInspectImage('ghcr.io/openclaw/openclaw:2026.4.12'),
|
||||
).rejects.toThrow('ambiguous source OCI digest')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,365 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
type S3Client,
|
||||
} from '@aws-sdk/client-s3'
|
||||
|
||||
import type { BuildResult } from '../src/build'
|
||||
import { publishAgents } from '../src/publish'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
function sha(char: string): string {
|
||||
return char.repeat(64)
|
||||
}
|
||||
|
||||
async function createBuildResult(
|
||||
root: string,
|
||||
arch: 'amd64' | 'arm64',
|
||||
overrides: Partial<BuildResult> = {},
|
||||
): Promise<BuildResult> {
|
||||
const dir = join(root, arch)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const tarballPath = join(dir, `openclaw-2026.4.12-${arch}.tar.gz`)
|
||||
const tarballShaPath = `${tarballPath}.sha256`
|
||||
await writeFile(tarballPath, `${arch}-tarball`, 'utf8')
|
||||
await writeFile(
|
||||
tarballShaPath,
|
||||
`${sha(arch === 'amd64' ? 'a' : 'b')} file\n`,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
return {
|
||||
name: 'openclaw',
|
||||
publishAs: 'openclaw',
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
arch,
|
||||
sourceOciDigest: `sha256:${sha('c')}`,
|
||||
imageId: `sha256:${sha(arch === 'amd64' ? 'd' : 'e')}`,
|
||||
smokeFingerprint: sha(arch === 'amd64' ? '6' : '7'),
|
||||
filename: `openclaw-2026.4.12-${arch}.tar.gz`,
|
||||
tarballPath,
|
||||
tarballShaPath,
|
||||
compressedSha256: sha(arch === 'amd64' ? '1' : '2'),
|
||||
compressedSizeBytes: 100,
|
||||
uncompressedSha256: sha(arch === 'amd64' ? '3' : '4'),
|
||||
uncompressedSizeBytes: 200,
|
||||
podmanVersion: 'podman version 5.8.1',
|
||||
builtAt: '2026-04-22T17:30:00.000Z',
|
||||
builtBy: 'workflow@refs/heads/dev',
|
||||
gitSha: 'abc123',
|
||||
gitDirty: false,
|
||||
configSha256: sha('5'),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function createTempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'agent-container-publish-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs
|
||||
.splice(0)
|
||||
.map((path) => rm(path, { recursive: true, force: true })),
|
||||
)
|
||||
})
|
||||
|
||||
describe('publish', () => {
|
||||
it('uploads version manifests and updates aggregate last', async () => {
|
||||
const root = await createTempDir()
|
||||
const buildResults = await Promise.all([
|
||||
createBuildResult(root, 'amd64'),
|
||||
createBuildResult(root, 'arm64'),
|
||||
])
|
||||
const puts: Array<{ key: string; body: unknown }> = []
|
||||
|
||||
const client = {
|
||||
send: async (command: unknown) => {
|
||||
if (command instanceof GetObjectCommand) {
|
||||
throw { name: 'NoSuchKey', $metadata: { httpStatusCode: 404 } }
|
||||
}
|
||||
if (command instanceof PutObjectCommand) {
|
||||
puts.push({
|
||||
key: String(command.input.Key),
|
||||
body: command.input.Body,
|
||||
})
|
||||
return {}
|
||||
}
|
||||
if (command instanceof DeleteObjectCommand) {
|
||||
return {}
|
||||
}
|
||||
throw new Error('unexpected command')
|
||||
},
|
||||
destroy: () => {},
|
||||
} as unknown as S3Client
|
||||
|
||||
await publishAgents({
|
||||
buildResults,
|
||||
updateAggregate: true,
|
||||
bucket: 'test-bucket',
|
||||
cdnBaseURL: 'https://cdn.example.com',
|
||||
client,
|
||||
now: () => new Date('2026-04-22T18:00:00.000Z'),
|
||||
})
|
||||
|
||||
expect(puts.map((entry) => entry.key)).toEqual([
|
||||
'agents/openclaw/2026.4.12/openclaw-2026.4.12-amd64.tar.gz',
|
||||
'agents/openclaw/2026.4.12/openclaw-2026.4.12-amd64.tar.gz.sha256',
|
||||
'agents/openclaw/2026.4.12/openclaw-2026.4.12-arm64.tar.gz',
|
||||
'agents/openclaw/2026.4.12/openclaw-2026.4.12-arm64.tar.gz.sha256',
|
||||
'agents/openclaw/2026.4.12/manifest.json',
|
||||
'agents/manifest.json',
|
||||
])
|
||||
|
||||
const versionManifest = JSON.parse(
|
||||
String(puts.find((entry) => entry.key.endsWith('/manifest.json'))?.body),
|
||||
)
|
||||
expect(versionManifest.source.oci_digest).toBe(`sha256:${sha('c')}`)
|
||||
expect(versionManifest.artifacts[0].url).toBe(
|
||||
'https://cdn.example.com/agents/openclaw/2026.4.12/openclaw-2026.4.12-amd64.tar.gz',
|
||||
)
|
||||
|
||||
const aggregateManifest = JSON.parse(String(puts.at(-1)?.body))
|
||||
expect(aggregateManifest.agents).toEqual([
|
||||
{
|
||||
name: 'openclaw',
|
||||
version: '2026.4.12',
|
||||
oci_digest: `sha256:${sha('c')}`,
|
||||
manifest_url:
|
||||
'https://cdn.example.com/agents/openclaw/2026.4.12/manifest.json',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('rolls back uploaded keys when a later upload fails', async () => {
|
||||
const root = await createTempDir()
|
||||
const buildResults = [await createBuildResult(root, 'amd64')]
|
||||
const deleted: string[] = []
|
||||
|
||||
const client = {
|
||||
send: async (command: unknown) => {
|
||||
if (command instanceof GetObjectCommand) {
|
||||
throw { name: 'NoSuchKey', $metadata: { httpStatusCode: 404 } }
|
||||
}
|
||||
if (command instanceof PutObjectCommand) {
|
||||
if (String(command.input.Key).endsWith('/manifest.json')) {
|
||||
throw new Error('manifest upload failed')
|
||||
}
|
||||
return {}
|
||||
}
|
||||
if (command instanceof DeleteObjectCommand) {
|
||||
deleted.push(String(command.input.Key))
|
||||
return {}
|
||||
}
|
||||
throw new Error('unexpected command')
|
||||
},
|
||||
destroy: () => {},
|
||||
} as unknown as S3Client
|
||||
|
||||
await expect(
|
||||
publishAgents({
|
||||
buildResults,
|
||||
updateAggregate: true,
|
||||
bucket: 'test-bucket',
|
||||
client,
|
||||
}),
|
||||
).rejects.toThrow('manifest upload failed')
|
||||
|
||||
expect(deleted).toEqual([
|
||||
'agents/openclaw/2026.4.12/openclaw-2026.4.12-amd64.tar.gz.sha256',
|
||||
'agents/openclaw/2026.4.12/openclaw-2026.4.12-amd64.tar.gz',
|
||||
])
|
||||
})
|
||||
|
||||
it('merges new entries into an existing aggregate manifest', async () => {
|
||||
const root = await createTempDir()
|
||||
const buildResults = [await createBuildResult(root, 'amd64')]
|
||||
const puts: Array<{ key: string; body: unknown }> = []
|
||||
|
||||
const client = {
|
||||
send: async (command: unknown) => {
|
||||
if (command instanceof GetObjectCommand) {
|
||||
return {
|
||||
Body: {
|
||||
transformToByteArray: async () =>
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
schema: 'v1',
|
||||
built_at: '2026-04-21T00:00:00.000Z',
|
||||
built_by: 'previous',
|
||||
agents: [
|
||||
{
|
||||
name: 'claude-code',
|
||||
version: '1.0.0',
|
||||
oci_digest: `sha256:${sha('9')}`,
|
||||
manifest_url:
|
||||
'https://cdn.example.com/agents/claude-code/1.0.0/manifest.json',
|
||||
},
|
||||
{
|
||||
name: 'openclaw',
|
||||
version: '2026.4.11',
|
||||
oci_digest: `sha256:${sha('8')}`,
|
||||
manifest_url:
|
||||
'https://cdn.example.com/agents/openclaw/2026.4.11/manifest.json',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
if (command instanceof PutObjectCommand) {
|
||||
puts.push({
|
||||
key: String(command.input.Key),
|
||||
body: command.input.Body,
|
||||
})
|
||||
return {}
|
||||
}
|
||||
if (command instanceof DeleteObjectCommand) {
|
||||
return {}
|
||||
}
|
||||
throw new Error('unexpected command')
|
||||
},
|
||||
destroy: () => {},
|
||||
} as unknown as S3Client
|
||||
|
||||
await publishAgents({
|
||||
buildResults,
|
||||
updateAggregate: true,
|
||||
bucket: 'test-bucket',
|
||||
cdnBaseURL: 'https://cdn.example.com',
|
||||
client,
|
||||
now: () => new Date('2026-04-22T18:00:00.000Z'),
|
||||
})
|
||||
|
||||
const aggregateManifest = JSON.parse(String(puts.at(-1)?.body))
|
||||
expect(aggregateManifest.agents).toEqual([
|
||||
{
|
||||
name: 'claude-code',
|
||||
version: '1.0.0',
|
||||
oci_digest: `sha256:${sha('9')}`,
|
||||
manifest_url:
|
||||
'https://cdn.example.com/agents/claude-code/1.0.0/manifest.json',
|
||||
},
|
||||
{
|
||||
name: 'openclaw',
|
||||
version: '2026.4.12',
|
||||
oci_digest: `sha256:${sha('c')}`,
|
||||
manifest_url:
|
||||
'https://cdn.example.com/agents/openclaw/2026.4.12/manifest.json',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('records distinct podman versions across arches', async () => {
|
||||
const root = await createTempDir()
|
||||
const buildResults = await Promise.all([
|
||||
createBuildResult(root, 'amd64', {
|
||||
podmanVersion: 'podman version 5.8.1',
|
||||
}),
|
||||
createBuildResult(root, 'arm64', {
|
||||
podmanVersion: 'podman version 5.9.0',
|
||||
}),
|
||||
])
|
||||
const puts: Array<{ key: string; body: unknown }> = []
|
||||
|
||||
const client = {
|
||||
send: async (command: unknown) => {
|
||||
if (command instanceof GetObjectCommand) {
|
||||
throw { name: 'NoSuchKey', $metadata: { httpStatusCode: 404 } }
|
||||
}
|
||||
if (command instanceof PutObjectCommand) {
|
||||
puts.push({
|
||||
key: String(command.input.Key),
|
||||
body: command.input.Body,
|
||||
})
|
||||
return {}
|
||||
}
|
||||
if (command instanceof DeleteObjectCommand) {
|
||||
return {}
|
||||
}
|
||||
throw new Error('unexpected command')
|
||||
},
|
||||
destroy: () => {},
|
||||
} as unknown as S3Client
|
||||
|
||||
await publishAgents({
|
||||
buildResults,
|
||||
updateAggregate: false,
|
||||
bucket: 'test-bucket',
|
||||
client,
|
||||
})
|
||||
|
||||
const versionManifest = JSON.parse(
|
||||
String(puts.find((entry) => entry.key.endsWith('/manifest.json'))?.body),
|
||||
)
|
||||
expect(versionManifest.build.podman_versions).toEqual([
|
||||
'podman version 5.8.1',
|
||||
'podman version 5.9.0',
|
||||
])
|
||||
})
|
||||
|
||||
it('records all build provenance values in the aggregate manifest', async () => {
|
||||
const root = await createTempDir()
|
||||
const buildResults = [
|
||||
await createBuildResult(root, 'amd64', {
|
||||
name: 'claude-code',
|
||||
publishAs: 'claude-code',
|
||||
image: 'ghcr.io/example/claude-code',
|
||||
version: '1.0.0',
|
||||
builtBy: 'workflow-a@refs/heads/dev',
|
||||
}),
|
||||
await createBuildResult(root, 'arm64', {
|
||||
name: 'openclaw',
|
||||
publishAs: 'openclaw',
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
builtBy: 'workflow-b@refs/heads/dev',
|
||||
}),
|
||||
]
|
||||
const puts: Array<{ key: string; body: unknown }> = []
|
||||
|
||||
const client = {
|
||||
send: async (command: unknown) => {
|
||||
if (command instanceof GetObjectCommand) {
|
||||
throw { name: 'NoSuchKey', $metadata: { httpStatusCode: 404 } }
|
||||
}
|
||||
if (command instanceof PutObjectCommand) {
|
||||
puts.push({
|
||||
key: String(command.input.Key),
|
||||
body: command.input.Body,
|
||||
})
|
||||
return {}
|
||||
}
|
||||
if (command instanceof DeleteObjectCommand) {
|
||||
return {}
|
||||
}
|
||||
throw new Error('unexpected command')
|
||||
},
|
||||
destroy: () => {},
|
||||
} as unknown as S3Client
|
||||
|
||||
await publishAgents({
|
||||
buildResults,
|
||||
updateAggregate: true,
|
||||
bucket: 'test-bucket',
|
||||
client,
|
||||
now: () => new Date('2026-04-22T18:00:00.000Z'),
|
||||
})
|
||||
|
||||
const aggregateManifest = JSON.parse(String(puts.at(-1)?.body))
|
||||
expect(aggregateManifest.built_by).toBe(
|
||||
'workflow-a@refs/heads/dev, workflow-b@refs/heads/dev',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
|
||||
import {
|
||||
keyForAggregateManifest,
|
||||
keyForSha,
|
||||
keyForTarball,
|
||||
keyForVersionManifest,
|
||||
} from '../src/schema/r2-keys'
|
||||
|
||||
describe('schema/r2-keys', () => {
|
||||
it('builds tarball keys', () => {
|
||||
expect(keyForTarball('openclaw', '2026.4.12', 'amd64')).toBe(
|
||||
'agents/openclaw/2026.4.12/openclaw-2026.4.12-amd64.tar.gz',
|
||||
)
|
||||
})
|
||||
|
||||
it('supports a custom publishAs filename prefix', () => {
|
||||
expect(keyForTarball('claude-code', '1.2.3', 'arm64', 'claude')).toBe(
|
||||
'agents/claude-code/1.2.3/claude-1.2.3-arm64.tar.gz',
|
||||
)
|
||||
expect(keyForSha('claude-code', '1.2.3', 'arm64', 'claude')).toBe(
|
||||
'agents/claude-code/1.2.3/claude-1.2.3-arm64.tar.gz.sha256',
|
||||
)
|
||||
})
|
||||
|
||||
it('builds manifest keys', () => {
|
||||
expect(keyForVersionManifest('openclaw', '2026.4.12')).toBe(
|
||||
'agents/openclaw/2026.4.12/manifest.json',
|
||||
)
|
||||
expect(keyForAggregateManifest()).toBe('agents/manifest.json')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
|
||||
import {
|
||||
parseAgentManifest,
|
||||
parseAggregateManifest,
|
||||
} from '../src/schema/manifest'
|
||||
|
||||
function hex(char: string): string {
|
||||
return char.repeat(64)
|
||||
}
|
||||
|
||||
describe('schema/manifest', () => {
|
||||
it('parses a valid agent manifest', () => {
|
||||
const manifest = parseAgentManifest({
|
||||
name: 'openclaw',
|
||||
schema: 'v1',
|
||||
build: {
|
||||
git_sha: 'abc123',
|
||||
git_dirty: false,
|
||||
built_at: '2026-04-22T17:30:00.000Z',
|
||||
built_by: 'workflow@refs/heads/dev',
|
||||
config_sha256: hex('0'),
|
||||
podman_versions: ['podman version 5.8.1'],
|
||||
},
|
||||
source: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
oci_digest: `sha256:${hex('1')}`,
|
||||
},
|
||||
artifacts: [
|
||||
{
|
||||
arch: 'amd64',
|
||||
filename: 'openclaw-2026.4.12-amd64.tar.gz',
|
||||
format: 'oci-archive+gzip',
|
||||
compressed_sha256: hex('2'),
|
||||
compressed_size_bytes: 123,
|
||||
uncompressed_sha256: hex('3'),
|
||||
uncompressed_size_bytes: 456,
|
||||
url: 'https://cdn.browseros.com/agents/openclaw/2026.4.12/openclaw-2026.4.12-amd64.tar.gz',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(manifest.source.version).toBe('2026.4.12')
|
||||
expect(manifest.artifacts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects invalid artifact hashes', () => {
|
||||
expect(() =>
|
||||
parseAgentManifest({
|
||||
name: 'openclaw',
|
||||
schema: 'v1',
|
||||
build: {
|
||||
git_sha: 'abc123',
|
||||
git_dirty: false,
|
||||
built_at: '2026-04-22T17:30:00.000Z',
|
||||
built_by: 'workflow@refs/heads/dev',
|
||||
config_sha256: hex('0'),
|
||||
podman_versions: ['podman version 5.8.1'],
|
||||
},
|
||||
source: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
oci_digest: `sha256:${hex('1')}`,
|
||||
},
|
||||
artifacts: [
|
||||
{
|
||||
arch: 'amd64',
|
||||
filename: 'openclaw-2026.4.12-amd64.tar.gz',
|
||||
format: 'oci-archive+gzip',
|
||||
compressed_sha256: 'bad',
|
||||
compressed_size_bytes: 123,
|
||||
uncompressed_sha256: hex('3'),
|
||||
uncompressed_size_bytes: 456,
|
||||
url: 'https://cdn.browseros.com/agents/openclaw/2026.4.12/openclaw-2026.4.12-amd64.tar.gz',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('parses a valid aggregate manifest', () => {
|
||||
const manifest = parseAggregateManifest({
|
||||
schema: 'v1',
|
||||
built_at: '2026-04-22T17:30:00.000Z',
|
||||
built_by: 'workflow@refs/heads/dev',
|
||||
agents: [
|
||||
{
|
||||
name: 'openclaw',
|
||||
version: '2026.4.12',
|
||||
oci_digest: `sha256:${hex('4')}`,
|
||||
manifest_url:
|
||||
'https://cdn.browseros.com/agents/openclaw/2026.4.12/manifest.json',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(manifest.agents[0]?.name).toBe('openclaw')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export const OPENCLAW_GATEWAY_PORT = 18789
|
||||
export const OPENCLAW_GATEWAY_CONTAINER_PORT = 18789
|
||||
export const OPENCLAW_CONTAINER_HOME = '/home/node/.openclaw'
|
||||
export const OPENCLAW_COMPOSE_PROJECT_NAME = 'browseros-openclaw'
|
||||
export const OPENCLAW_GATEWAY_CONTAINER_NAME = `${OPENCLAW_COMPOSE_PROJECT_NAME}-openclaw-gateway-1`
|
||||
|
||||
@@ -1,242 +1,26 @@
|
||||
{
|
||||
"resources": [
|
||||
{
|
||||
"name": "Bun Runtime - macOS ARM64",
|
||||
"name": "Lima limactl - macOS ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/bun/bun-darwin-arm64"
|
||||
"key": "third_party/lima/limactl-darwin-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/bun",
|
||||
"destination": "resources/bin/third_party/lima/limactl",
|
||||
"os": ["macos"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Bun Runtime - macOS x64",
|
||||
"name": "Lima limactl - macOS x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/bun/bun-darwin-x64"
|
||||
"key": "third_party/lima/limactl-darwin-x64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/bun",
|
||||
"destination": "resources/bin/third_party/lima/limactl",
|
||||
"os": ["macos"],
|
||||
"arch": ["x64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Bun Runtime - Linux ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/bun/bun-linux-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/bun",
|
||||
"os": ["linux"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Bun Runtime - Linux x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/bun/bun-linux-x64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/bun",
|
||||
"os": ["linux"],
|
||||
"arch": ["x64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Bun Runtime - Windows x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/bun/bun-windows-x64.exe"
|
||||
},
|
||||
"destination": "resources/bin/third_party/bun.exe",
|
||||
"os": ["windows"],
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"name": "Podman CLI - macOS ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/podman-darwin-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/podman",
|
||||
"os": ["macos"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman gvproxy - macOS ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/gvproxy-darwin-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/gvproxy",
|
||||
"os": ["macos"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman vfkit - macOS ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/vfkit-darwin-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/vfkit",
|
||||
"os": ["macos"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman krunkit - macOS ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/krunkit-darwin-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/krunkit",
|
||||
"os": ["macos"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman mac helper - macOS ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/podman-mac-helper-darwin-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/podman-mac-helper",
|
||||
"os": ["macos"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman CLI - macOS x64",
|
||||
"notes": "krunkit is intentionally omitted on macOS x64 because the official amd64 Podman installer ships an arm64-only krunkit helper",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/podman-darwin-x64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/podman",
|
||||
"os": ["macos"],
|
||||
"arch": ["x64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman gvproxy - macOS x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/gvproxy-darwin-x64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/gvproxy",
|
||||
"os": ["macos"],
|
||||
"arch": ["x64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman vfkit - macOS x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/vfkit-darwin-x64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/vfkit",
|
||||
"os": ["macos"],
|
||||
"arch": ["x64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman mac helper - macOS x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/podman-mac-helper-darwin-x64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/podman-mac-helper",
|
||||
"os": ["macos"],
|
||||
"arch": ["x64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman CLI - Windows x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/podman-windows-x64.exe"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/podman.exe",
|
||||
"os": ["windows"],
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"name": "Podman gvproxy - Windows x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/gvproxy-windows-x64.exe"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/gvproxy.exe",
|
||||
"os": ["windows"],
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"name": "Podman win-sshproxy - Windows x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/win-sshproxy-windows-x64.exe"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/win-sshproxy.exe",
|
||||
"os": ["windows"],
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"name": "ripgrep - macOS ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/ripgrep/rg-darwin-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/rg",
|
||||
"os": ["macos"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "ripgrep - macOS x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/ripgrep/rg-darwin-x64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/rg",
|
||||
"os": ["macos"],
|
||||
"arch": ["x64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "ripgrep - Linux ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/ripgrep/rg-linux-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/rg",
|
||||
"os": ["linux"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "ripgrep - Linux x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/ripgrep/rg-linux-x64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/rg",
|
||||
"os": ["linux"],
|
||||
"arch": ["x64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "ripgrep - Windows x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/ripgrep/rg-windows-x64.exe"
|
||||
},
|
||||
"destination": "resources/bin/third_party/rg.exe",
|
||||
"os": ["windows"],
|
||||
"arch": ["x64"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,9 +12,12 @@ function validateRule(rule: ResourceRule): void {
|
||||
if (!rule.name || rule.name.trim().length === 0) {
|
||||
throw new Error('Manifest rule is missing name')
|
||||
}
|
||||
if (!rule.source.key || !rule.destination) {
|
||||
const hasSourcePath =
|
||||
(rule.source.type === 'r2' && rule.source.key) ||
|
||||
(rule.source.type === 'local' && rule.source.path)
|
||||
if (!hasSourcePath || !rule.destination) {
|
||||
throw new Error(
|
||||
`Manifest rule ${rule.name} is missing source key or destination`,
|
||||
`Manifest rule ${rule.name} is missing source path or destination`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -24,16 +27,21 @@ function parseSource(raw: unknown): ResourceRule['source'] {
|
||||
throw new Error('Manifest source must be an object')
|
||||
}
|
||||
const source = raw as Record<string, unknown>
|
||||
if (source.type !== 'r2') {
|
||||
throw new Error(
|
||||
`Unsupported source type in manifest: ${String(source.type)}`,
|
||||
)
|
||||
if (source.type === 'r2') {
|
||||
const key = source.key
|
||||
if (typeof key !== 'string' || key.length === 0) {
|
||||
throw new Error('Manifest source key is required')
|
||||
}
|
||||
return { type: 'r2', key }
|
||||
}
|
||||
const key = source.key
|
||||
if (typeof key !== 'string' || key.length === 0) {
|
||||
throw new Error('Manifest source key is required')
|
||||
if (source.type === 'local') {
|
||||
const path = source.path
|
||||
if (typeof path !== 'string' || path.length === 0) {
|
||||
throw new Error('Manifest source path is required')
|
||||
}
|
||||
return { type: 'local', path }
|
||||
}
|
||||
return { type: 'r2', key }
|
||||
throw new Error(`Unsupported source type in manifest: ${String(source.type)}`)
|
||||
}
|
||||
|
||||
function parseRule(raw: unknown): ResourceRule {
|
||||
|
||||
@@ -34,17 +34,28 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
|
||||
{ ci: args.ci },
|
||||
)
|
||||
|
||||
const manifestPath = resolve(rootDir, args.manifestPath)
|
||||
if (!existsSync(manifestPath)) {
|
||||
throw new Error(`Manifest not found: ${manifestPath}`)
|
||||
}
|
||||
const manifest = loadManifest(manifestPath)
|
||||
|
||||
if (args.ci) {
|
||||
const distRoot = getDistProdRoot()
|
||||
const localArtifacts = []
|
||||
|
||||
for (const binary of compiled) {
|
||||
log.step(`Packaging ${binary.target.name}`)
|
||||
const rules = getTargetRules(manifest, binary.target).filter(
|
||||
(rule) => rule.source.type === 'local',
|
||||
)
|
||||
const staged = await stageCompiledArtifact(
|
||||
distRoot,
|
||||
binary.binaryPath,
|
||||
binary.target,
|
||||
buildConfig.version,
|
||||
rules,
|
||||
rootDir,
|
||||
)
|
||||
localArtifacts.push(staged)
|
||||
log.success(`Packaged ${binary.target.id}`)
|
||||
@@ -58,12 +69,6 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
const manifestPath = resolve(rootDir, args.manifestPath)
|
||||
if (!existsSync(manifestPath)) {
|
||||
throw new Error(`Manifest not found: ${manifestPath}`)
|
||||
}
|
||||
|
||||
const manifest = loadManifest(manifestPath)
|
||||
const distRoot = getDistProdRoot()
|
||||
const r2 = buildConfig.r2
|
||||
if (!r2) {
|
||||
@@ -76,13 +81,14 @@ export async function runProdResourceBuild(argv: string[]): Promise<void> {
|
||||
for (const binary of compiled) {
|
||||
const rules = getTargetRules(manifest, binary.target)
|
||||
log.step(
|
||||
`Staging ${binary.target.name} (${rules.length} download rule(s))`,
|
||||
`Staging ${binary.target.name} (${rules.length} resource rule(s))`,
|
||||
)
|
||||
const staged = await stageTargetArtifact(
|
||||
distRoot,
|
||||
binary.binaryPath,
|
||||
binary.target,
|
||||
rules,
|
||||
rootDir,
|
||||
client,
|
||||
r2,
|
||||
buildConfig.version,
|
||||
|
||||
26
packages/browseros-agent/scripts/build/server/stage.test.ts
Normal file
26
packages/browseros-agent/scripts/build/server/stage.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { loadManifest } from './manifest'
|
||||
|
||||
describe('server artifact staging', () => {
|
||||
let tempDir: string | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
tempDir = null
|
||||
}
|
||||
})
|
||||
|
||||
it('loads empty local-resource rules from the manifest', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'browseros-stage-test-'))
|
||||
const manifestPath = join(tempDir, 'manifest.json')
|
||||
await writeFile(manifestPath, JSON.stringify({ resources: [] }))
|
||||
|
||||
expect(loadManifest(manifestPath)).toEqual({
|
||||
resources: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { chmod, cp, mkdir, rm } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative } from 'node:path'
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
|
||||
import type { S3Client } from '@aws-sdk/client-s3'
|
||||
|
||||
@@ -75,13 +75,40 @@ function resolveDestination(rootDir: string, destination: string): string {
|
||||
|
||||
async function stageRule(
|
||||
rootDir: string,
|
||||
sourceRoot: string,
|
||||
rule: ResourceRule,
|
||||
target: BuildTarget,
|
||||
client: S3Client,
|
||||
r2: R2Config,
|
||||
): Promise<void> {
|
||||
const destinationPath = resolveDestination(rootDir, rule.destination)
|
||||
await downloadObjectToFile(client, r2, rule.source.key, destinationPath)
|
||||
await mkdir(dirname(destinationPath), { recursive: true })
|
||||
|
||||
if (rule.source.type === 'local') {
|
||||
await stageLocalRule(destinationPath, sourceRoot, rule, target)
|
||||
} else {
|
||||
await downloadObjectToFile(client, r2, rule.source.key, destinationPath)
|
||||
if (rule.executable && target.os !== 'windows') {
|
||||
await chmod(destinationPath, 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stageLocalRule(
|
||||
destinationPath: string,
|
||||
sourceRoot: string,
|
||||
rule: ResourceRule,
|
||||
target: BuildTarget,
|
||||
): Promise<void> {
|
||||
if (rule.source.type !== 'local') {
|
||||
throw new Error(`Expected local source rule, got ${rule.source.type}`)
|
||||
}
|
||||
|
||||
await mkdir(dirname(destinationPath), { recursive: true })
|
||||
const sourcePath = isAbsolute(rule.source.path)
|
||||
? rule.source.path
|
||||
: resolve(sourceRoot, rule.source.path)
|
||||
await cp(sourcePath, destinationPath)
|
||||
|
||||
if (rule.executable && target.os !== 'windows') {
|
||||
await chmod(destinationPath, 0o755)
|
||||
@@ -93,6 +120,7 @@ export async function stageTargetArtifact(
|
||||
compiledBinaryPath: string,
|
||||
target: BuildTarget,
|
||||
rules: ResourceRule[],
|
||||
sourceRoot: string,
|
||||
client: S3Client,
|
||||
r2: R2Config,
|
||||
version: string,
|
||||
@@ -100,7 +128,7 @@ export async function stageTargetArtifact(
|
||||
const rootDir = await createArtifactRoot(distRoot, compiledBinaryPath, target)
|
||||
|
||||
for (const rule of rules) {
|
||||
await stageRule(rootDir, rule, target, client, r2)
|
||||
await stageRule(rootDir, sourceRoot, rule, target, client, r2)
|
||||
}
|
||||
|
||||
return finalizeArtifact(rootDir, target, version)
|
||||
@@ -111,7 +139,22 @@ export async function stageCompiledArtifact(
|
||||
compiledBinaryPath: string,
|
||||
target: BuildTarget,
|
||||
version: string,
|
||||
rules: ResourceRule[] = [],
|
||||
sourceRoot = process.cwd(),
|
||||
): Promise<StagedArtifact> {
|
||||
const rootDir = await createArtifactRoot(distRoot, compiledBinaryPath, target)
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.source.type !== 'local') {
|
||||
continue
|
||||
}
|
||||
await stageLocalRule(
|
||||
resolveDestination(rootDir, rule.destination),
|
||||
sourceRoot,
|
||||
rule,
|
||||
target,
|
||||
)
|
||||
}
|
||||
|
||||
return finalizeArtifact(rootDir, target, version)
|
||||
}
|
||||
|
||||
@@ -40,11 +40,18 @@ export interface BuildConfig {
|
||||
r2?: R2Config
|
||||
}
|
||||
|
||||
export interface ResourceSource {
|
||||
export interface R2ResourceSource {
|
||||
type: 'r2'
|
||||
key: string
|
||||
}
|
||||
|
||||
export interface LocalResourceSource {
|
||||
type: 'local'
|
||||
path: string
|
||||
}
|
||||
|
||||
export type ResourceSource = R2ResourceSource | LocalResourceSource
|
||||
|
||||
export interface ResourceRule {
|
||||
name: string
|
||||
source: ResourceSource
|
||||
|
||||
29
packages/browseros-agent/scripts/run-bun-test.ts
Normal file
29
packages/browseros-agent/scripts/run-bun-test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const projectRoot = resolve(import.meta.dir, '..')
|
||||
const junitPath = process.env.BROWSEROS_JUNIT_PATH?.trim()
|
||||
const testArgs = process.argv.slice(2)
|
||||
|
||||
const cmd = [process.execPath, 'test']
|
||||
|
||||
if (junitPath) {
|
||||
const outputPath = resolve(projectRoot, junitPath)
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
cmd.push('--reporter=junit', `--reporter-outfile=${outputPath}`)
|
||||
}
|
||||
|
||||
cmd.push(...testArgs)
|
||||
|
||||
const result = spawnSync(cmd[0], cmd.slice(1), {
|
||||
cwd: projectRoot,
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"browseros-dev/proc"
|
||||
|
||||
@@ -33,7 +34,9 @@ func runCleanup(cmd *cobra.Command, args []string) error {
|
||||
if doPorts {
|
||||
ports := proc.DefaultLocalPorts()
|
||||
proc.LogMsgf(proc.TagInfo, "Killing processes on ports %d, %d, %d...", ports.CDP, ports.Server, ports.Extension)
|
||||
proc.KillPorts(ports)
|
||||
if err := proc.KillPortsAndWait(ports, 3*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
proc.LogMsg(proc.TagInfo, "Ports cleared")
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"browseros-dev/browser"
|
||||
"browseros-dev/proc"
|
||||
@@ -62,7 +63,9 @@ func runWatch(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("creating user-data dir: %w", err)
|
||||
}
|
||||
proc.LogMsg(proc.TagInfo, "Killing processes on preferred ports...")
|
||||
proc.KillPorts(defaultPorts)
|
||||
if err := proc.KillPortsAndWait(defaultPorts, 3*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
proc.LogMsg(proc.TagInfo, "Ports cleared")
|
||||
|
||||
p, reservations, err = proc.ResolveWatchPorts(false)
|
||||
@@ -159,6 +162,9 @@ func runWatch(cmd *cobra.Command, args []string) error {
|
||||
Env: env,
|
||||
Restart: true,
|
||||
Cmd: []string{"bun", "--watch", "--env-file=.env.development", "src/index.ts"},
|
||||
BeforeStart: func() error {
|
||||
return proc.KillPortAndWait(p.Server, 3*time.Second)
|
||||
},
|
||||
}))
|
||||
|
||||
<-sigCh
|
||||
|
||||
@@ -11,11 +11,12 @@ import (
|
||||
)
|
||||
|
||||
type ProcConfig struct {
|
||||
Tag Tag
|
||||
Dir string
|
||||
Env []string
|
||||
Restart bool
|
||||
Cmd []string
|
||||
Tag Tag
|
||||
Dir string
|
||||
Env []string
|
||||
Restart bool
|
||||
Cmd []string
|
||||
BeforeStart func() error
|
||||
}
|
||||
|
||||
type ManagedProc struct {
|
||||
@@ -49,6 +50,17 @@ func (mp *ManagedProc) run(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if mp.Cfg.BeforeStart != nil {
|
||||
if err := mp.Cfg.BeforeStart(); err != nil {
|
||||
LogMsg(mp.Cfg.Tag, ErrorColor.Sprintf("Pre-start failed: %v", err))
|
||||
if !mp.Cfg.Restart || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
LogMsgf(mp.Cfg.Tag, "Starting: %s", DimColor.Sprint(strings.Join(mp.Cfg.Cmd, " ")))
|
||||
|
||||
cmd := exec.Command(mp.Cfg.Cmd[0], mp.Cfg.Cmd[1:]...)
|
||||
|
||||
60
packages/browseros-agent/tools/dev/proc/managed_test.go
Normal file
60
packages/browseros-agent/tools/dev/proc/managed_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStartManagedRunsBeforeStartOnEachRetry(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
var count atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
|
||||
StartManaged(ctx, &wg, ProcConfig{
|
||||
Tag: TagInfo,
|
||||
Dir: t.TempDir(),
|
||||
Restart: true,
|
||||
Cmd: []string{"sh", "-c", "exit 1"},
|
||||
BeforeStart: func() error {
|
||||
count.Add(1)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if count.Load() < 2 {
|
||||
t.Fatalf("expected BeforeStart to run on retries, got %d calls", count.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartManagedSkipsLaunchWhenBeforeStartFails(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sentinel := filepath.Join(t.TempDir(), "started")
|
||||
var wg sync.WaitGroup
|
||||
|
||||
StartManaged(ctx, &wg, ProcConfig{
|
||||
Tag: TagInfo,
|
||||
Dir: t.TempDir(),
|
||||
Restart: false,
|
||||
Cmd: []string{"sh", "-c", "touch " + sentinel},
|
||||
BeforeStart: func() error {
|
||||
return context.DeadlineExceeded
|
||||
},
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected process launch to be skipped, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,29 @@ func KillPort(port int) {
|
||||
exec.Command("sh", "-c", fmt.Sprintf("lsof -ti:%d | xargs kill -9 2>/dev/null || true", port)).Run()
|
||||
}
|
||||
|
||||
func KillPortAndWait(port int, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
KillPort(port)
|
||||
if IsPortAvailable(port) {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("port %d is still in use after kill -9 cleanup", port)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func KillPortsAndWait(p Ports, timeout time.Duration) error {
|
||||
for _, port := range []int{p.CDP, p.Server, p.Extension} {
|
||||
if err := KillPortAndWait(port, timeout); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func BuildEnv(p Ports, nodeEnv string) []string {
|
||||
env := os.Environ()
|
||||
env = append(env,
|
||||
|
||||
4
packages/browseros/build/browseros.py
generated
4
packages/browseros/build/browseros.py
generated
@@ -44,6 +44,10 @@ app.add_typer(release.app, name="release", help="Release automation")
|
||||
from .cli import ota
|
||||
app.add_typer(ota.app, name="ota", help="OTA update automation")
|
||||
|
||||
# Third-party resource uploads (Lima, future VM disk + agent tarballs)
|
||||
from .cli import storage
|
||||
app.add_typer(storage.app, name="upload", help="Upload third-party resources to R2")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
||||
269
packages/browseros/build/cli/storage.py
generated
Normal file
269
packages/browseros/build/cli/storage.py
generated
Normal file
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Storage CLI - Push third-party resources to R2 for build:server ingestion."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tarfile
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
import typer
|
||||
|
||||
from ..common.env import EnvConfig
|
||||
from ..common.utils import log_error, log_info, log_success, log_warning
|
||||
from ..modules.storage.r2 import (
|
||||
BOTO3_AVAILABLE,
|
||||
get_r2_client,
|
||||
upload_file_to_r2,
|
||||
)
|
||||
|
||||
LIMA_RELEASE_BASE = "https://github.com/lima-vm/lima/releases/download"
|
||||
LIMA_R2_PREFIX = "third_party/lima"
|
||||
LIMA_MANIFEST_KEY = f"{LIMA_R2_PREFIX}/manifest.json"
|
||||
LIMA_HTTP_TIMEOUT_S = 60
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LimaArch:
|
||||
"""Arch-pair: the suffix Lima uses upstream and the suffix we use in R2."""
|
||||
|
||||
internal: str # "arm64" | "x64" — how our R2 keys name it
|
||||
upstream: str # "Darwin-arm64" | "Darwin-x86_64" — Lima's tarball suffix
|
||||
|
||||
|
||||
LIMA_ARCHES: Tuple[LimaArch, ...] = (
|
||||
LimaArch(internal="arm64", upstream="Darwin-arm64"),
|
||||
LimaArch(internal="x64", upstream="Darwin-x86_64"),
|
||||
)
|
||||
|
||||
|
||||
app = typer.Typer(
|
||||
help="Upload third-party resources to Cloudflare R2",
|
||||
pretty_exceptions_enable=False,
|
||||
pretty_exceptions_show_locals=False,
|
||||
)
|
||||
|
||||
|
||||
@app.command("lima")
|
||||
def upload_lima(
|
||||
version: str = typer.Option(
|
||||
...,
|
||||
"--version",
|
||||
"-v",
|
||||
help="Lima release tag, e.g. v1.2.3",
|
||||
),
|
||||
dry_run: bool = typer.Option(
|
||||
False,
|
||||
"--dry-run",
|
||||
help="Download + verify only; skip R2 uploads.",
|
||||
),
|
||||
) -> None:
|
||||
"""Download limactl from a Lima GitHub release and push to R2."""
|
||||
if not BOTO3_AVAILABLE:
|
||||
log_error("boto3 not installed — run: pip install boto3")
|
||||
raise typer.Exit(1)
|
||||
|
||||
env = EnvConfig()
|
||||
if not env.has_r2_config():
|
||||
log_error(
|
||||
"R2 configuration missing. Required: "
|
||||
"R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
tag = _normalize_version_tag(version)
|
||||
client = None if dry_run else get_r2_client(env)
|
||||
if not dry_run and client is None:
|
||||
log_error("Failed to create R2 client")
|
||||
raise typer.Exit(1)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="lima-upload-") as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
checksums = _fetch_checksums(tag, tmp_dir)
|
||||
uploaded_keys: List[str] = []
|
||||
object_shas: Dict[str, str] = {}
|
||||
tarball_shas: Dict[str, str] = {}
|
||||
|
||||
try:
|
||||
for arch in LIMA_ARCHES:
|
||||
tarball_sha, object_sha, r2_key = _process_arch(
|
||||
tag, arch, tmp_dir, checksums, client, env, dry_run
|
||||
)
|
||||
tarball_shas[arch.internal] = tarball_sha
|
||||
object_shas[arch.internal] = object_sha
|
||||
if not dry_run:
|
||||
uploaded_keys.append(r2_key)
|
||||
|
||||
manifest = _build_manifest(tag, tarball_shas, object_shas)
|
||||
_upload_manifest(client, env, manifest, tmp_dir, dry_run)
|
||||
# Any failure mid-loop (download, sha verify, extract, upload) must
|
||||
# roll back prior arch uploads so R2 never holds a mixed-version pair.
|
||||
except Exception as exc:
|
||||
if not dry_run and uploaded_keys:
|
||||
log_warning(f"Upload failed — rolling back {len(uploaded_keys)} object(s)")
|
||||
_rollback(client, env.r2_bucket, uploaded_keys)
|
||||
log_error(f"Lima upload aborted: {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
log_success(f"Lima {tag} uploaded for {[a.internal for a in LIMA_ARCHES]}")
|
||||
|
||||
|
||||
def _normalize_version_tag(version: str) -> str:
|
||||
return version if version.startswith("v") else f"v{version}"
|
||||
|
||||
|
||||
def _fetch_checksums(tag: str, tmp_dir: Path) -> Dict[str, str]:
|
||||
url = f"{LIMA_RELEASE_BASE}/{tag}/SHA256SUMS"
|
||||
dest = tmp_dir / "SHA256SUMS"
|
||||
log_info(f"Fetching {url}")
|
||||
_download(url, dest)
|
||||
return _parse_checksums(dest.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _parse_checksums(contents: str) -> Dict[str, str]:
|
||||
"""Parse lines like '<sha256> lima-1.2.3-Darwin-arm64.tar.gz'."""
|
||||
entries: Dict[str, str] = {}
|
||||
for raw_line in contents.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) != 2:
|
||||
raise RuntimeError(f"Malformed SHA256SUMS line: {raw_line!r}")
|
||||
sha, name = parts[0].lower(), parts[1].lstrip("*").strip()
|
||||
if len(sha) != 64 or not all(c in "0123456789abcdef" for c in sha):
|
||||
raise RuntimeError(f"Invalid sha256 in SHA256SUMS: {raw_line!r}")
|
||||
entries[name] = sha
|
||||
return entries
|
||||
|
||||
|
||||
def _process_arch(
|
||||
tag: str,
|
||||
arch: LimaArch,
|
||||
tmp_dir: Path,
|
||||
checksums: Dict[str, str],
|
||||
client: Any,
|
||||
env: EnvConfig,
|
||||
dry_run: bool,
|
||||
) -> Tuple[str, str, str]:
|
||||
version_num = tag.lstrip("v")
|
||||
tarball_name = f"lima-{version_num}-{arch.upstream}.tar.gz"
|
||||
expected_sha = checksums.get(tarball_name)
|
||||
if not expected_sha:
|
||||
raise RuntimeError(
|
||||
f"{tarball_name} missing from SHA256SUMS (is the version tag correct?)"
|
||||
)
|
||||
|
||||
tarball_path = tmp_dir / tarball_name
|
||||
url = f"{LIMA_RELEASE_BASE}/{tag}/{tarball_name}"
|
||||
log_info(f"Downloading {url}")
|
||||
_download(url, tarball_path)
|
||||
|
||||
actual_sha = _sha256_file(tarball_path)
|
||||
if actual_sha != expected_sha:
|
||||
raise RuntimeError(
|
||||
f"sha256 mismatch for {tarball_name}: "
|
||||
f"expected {expected_sha}, got {actual_sha}"
|
||||
)
|
||||
|
||||
limactl_path = tmp_dir / f"limactl-darwin-{arch.internal}"
|
||||
_extract_limactl(tarball_path, limactl_path)
|
||||
object_sha = _sha256_file(limactl_path)
|
||||
|
||||
r2_key = f"{LIMA_R2_PREFIX}/limactl-darwin-{arch.internal}"
|
||||
if dry_run:
|
||||
log_info(f"[dry-run] skipped upload of {r2_key}")
|
||||
else:
|
||||
if not upload_file_to_r2(client, limactl_path, r2_key, env.r2_bucket):
|
||||
raise RuntimeError(f"Failed to upload {r2_key}")
|
||||
|
||||
return actual_sha, object_sha, r2_key
|
||||
|
||||
|
||||
def _extract_limactl(tarball_path: Path, dest: Path) -> None:
|
||||
"""Extract the single `bin/limactl` entry to dest."""
|
||||
with tarfile.open(tarball_path, "r:gz") as tar:
|
||||
member = _find_limactl_member(tar)
|
||||
extracted = tar.extractfile(member)
|
||||
if extracted is None:
|
||||
raise RuntimeError(f"{member.name} is not a regular file")
|
||||
with extracted as src, open(dest, "wb") as out:
|
||||
while chunk := src.read(1024 * 1024):
|
||||
out.write(chunk)
|
||||
dest.chmod(0o755)
|
||||
|
||||
|
||||
def _find_limactl_member(tar: tarfile.TarFile) -> tarfile.TarInfo:
|
||||
for member in tar.getmembers():
|
||||
if not member.isfile():
|
||||
continue
|
||||
parts = Path(member.name).parts
|
||||
if len(parts) >= 2 and parts[-2:] == ("bin", "limactl"):
|
||||
return member
|
||||
raise RuntimeError("bin/limactl not found in Lima tarball")
|
||||
|
||||
|
||||
def _build_manifest(
|
||||
tag: str,
|
||||
tarball_shas: Dict[str, str],
|
||||
object_shas: Dict[str, str],
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"lima_version": tag,
|
||||
"tarball_shas_upstream": tarball_shas,
|
||||
"r2_object_shas": object_shas,
|
||||
"uploaded_at": datetime.now(timezone.utc).isoformat(),
|
||||
# Prefer CI context so we don't leak an individual's OS login when
|
||||
# running locally. manifest.json is surfaced via the public CDN.
|
||||
"uploaded_by": os.environ.get("GITHUB_ACTOR") or "local",
|
||||
}
|
||||
|
||||
|
||||
def _upload_manifest(
|
||||
client: Any,
|
||||
env: EnvConfig,
|
||||
manifest: Dict[str, Any],
|
||||
tmp_dir: Path,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
manifest_path = tmp_dir / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
if dry_run:
|
||||
log_info(f"[dry-run] manifest would be: {manifest}")
|
||||
return
|
||||
if not upload_file_to_r2(client, manifest_path, LIMA_MANIFEST_KEY, env.r2_bucket):
|
||||
raise RuntimeError(f"Failed to upload {LIMA_MANIFEST_KEY}")
|
||||
|
||||
|
||||
def _rollback(client: Any, bucket: str, keys: List[str]) -> None:
|
||||
for key in keys:
|
||||
try:
|
||||
client.delete_object(Bucket=bucket, Key=key)
|
||||
log_info(f"Rolled back {key}")
|
||||
except Exception as exc:
|
||||
log_warning(f"Rollback failed for {key}: {exc}")
|
||||
|
||||
|
||||
def _download(url: str, dest: Path, *, timeout: Optional[int] = None) -> None:
|
||||
response = requests.get(url, stream=True, timeout=timeout or LIMA_HTTP_TIMEOUT_S)
|
||||
response.raise_for_status()
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(dest, "wb") as out:
|
||||
for chunk in response.iter_content(chunk_size=1024 * 1024):
|
||||
if chunk:
|
||||
out.write(chunk)
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
sha = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
sha.update(chunk)
|
||||
return sha.hexdigest()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user