mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
Compare commits
2 Commits
fix/github
...
fix/podman
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
797c7ea24e | ||
|
|
dad6753645 |
153
.github/workflows/build-agent.yml
vendored
153
.github/workflows/build-agent.yml
vendored
@@ -1,153 +0,0 @@
|
||||
name: build-agent
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
agent:
|
||||
description: "Agent name from bundle.json"
|
||||
required: true
|
||||
type: string
|
||||
default: openclaw
|
||||
publish:
|
||||
description: "Upload to R2 and merge manifest slice"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
BUN_VERSION: "1.3.6"
|
||||
PKG_DIR: packages/browseros-agent/packages/build-tools
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun install --frozen-lockfile
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun run --filter @browseros/build-tools typecheck
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun run --filter @browseros/build-tools test
|
||||
|
||||
build:
|
||||
needs: check
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
- name: Install podman
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y podman
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Build tarball
|
||||
working-directory: ${{ env.PKG_DIR }}
|
||||
env:
|
||||
AGENT: ${{ inputs.agent || 'openclaw' }}
|
||||
OUT: ${{ github.workspace }}/dist/images
|
||||
run: bun run build:tarball -- --agent "$AGENT" --arch "${{ matrix.arch }}" --output-dir "$OUT"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tarball-${{ inputs.agent || 'openclaw' }}-${{ matrix.arch }}
|
||||
path: dist/images/
|
||||
retention-days: 7
|
||||
|
||||
smoke:
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tarball-${{ inputs.agent || 'openclaw' }}-arm64
|
||||
path: dist/images
|
||||
- name: Install podman
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y podman
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Smoke test tarball
|
||||
working-directory: ${{ env.PKG_DIR }}
|
||||
env:
|
||||
AGENT: ${{ inputs.agent || 'openclaw' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tarball="$(find "$GITHUB_WORKSPACE/dist/images" -name "${AGENT}-*-arm64.tar.gz" -print -quit)"
|
||||
if [ -z "$tarball" ]; then
|
||||
echo "missing arm64 tarball artifact for ${AGENT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
bun run smoke:tarball -- --agent "$AGENT" --arch arm64 --tarball "$tarball"
|
||||
|
||||
publish:
|
||||
needs: [build, smoke]
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish == true }}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: release
|
||||
concurrency:
|
||||
group: r2-manifest-publish
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: tarball-*
|
||||
path: dist/images
|
||||
merge-multiple: true
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Upload tarballs to R2
|
||||
working-directory: ${{ env.PKG_DIR }}
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for file in "$GITHUB_WORKSPACE"/dist/images/*.tar.gz; do
|
||||
base="$(basename "$file")"
|
||||
bun run upload -- --file "$file" --key "vm/images/$base" --content-type "application/gzip" --sidecar-sha
|
||||
done
|
||||
- name: Merge agent slice into manifest
|
||||
working-directory: ${{ env.PKG_DIR }}
|
||||
env:
|
||||
AGENT: ${{ inputs.agent || 'openclaw' }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist/images
|
||||
cp -R "$GITHUB_WORKSPACE"/dist/images/* dist/images/
|
||||
bun run download -- --key vm/manifest.json --out dist/baseline-manifest.json
|
||||
bun run emit-manifest -- \
|
||||
--slice "agents:${AGENT}" \
|
||||
--dist-dir dist \
|
||||
--merge-from dist/baseline-manifest.json \
|
||||
--out dist/manifest.json
|
||||
bun run upload -- --file dist/manifest.json --key vm/manifest.json --content-type "application/json"
|
||||
1
packages/browseros-agent/.gitignore
vendored
1
packages/browseros-agent/.gitignore
vendored
@@ -14,7 +14,6 @@ lerna-debug.log*
|
||||
# Ignore all .env files except .env.example
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
!**/.env.sample
|
||||
!**/.env.production.example
|
||||
|
||||
|
||||
|
||||
@@ -218,9 +218,3 @@ This uses the same element resolution as the server's MCP tools — no coordinat
|
||||
The `<target>` argument can be:
|
||||
- An **index** from the `targets` output (e.g., `3`)
|
||||
- A **URL substring** (e.g., `sidepanel`, `newtab`, `chrome-extension://`)
|
||||
|
||||
## Release gating — bundled-VM runtime migration (2026-Q2)
|
||||
|
||||
Between the Lima server-prod-resources cutover (WS3) and the ContainerRuntime migration (WS6) landing, `resources/bin/third_party/` ships `limactl` instead of `podman`. The current OpenClaw runtime (`apps/server/src/api/services/openclaw/podman-runtime.ts`, `container-runtime.ts`) still invokes `podman`; it will fail to find the binary on builds cut from `dev`.
|
||||
|
||||
Do **not** cut a release branch off `dev` during this window. Track WS6 progress before any release cut. See `specs/bundled-vm-runtime-spec.md` + `specs/workstreams.md` for context.
|
||||
|
||||
@@ -75,20 +75,26 @@ packages/
|
||||
|
||||
### Setup
|
||||
|
||||
Requires [process-compose](https://github.com/F1bonacc1/process-compose):
|
||||
|
||||
```bash
|
||||
brew install process-compose
|
||||
```
|
||||
|
||||
```bash
|
||||
# Copy environment files for each package
|
||||
cp apps/server/.env.example apps/server/.env.development
|
||||
cp apps/agent/.env.example apps/agent/.env.development
|
||||
cp apps/server/.env.production.example apps/server/.env.production
|
||||
|
||||
# Install deps, generate agent code, and sync the VM cache
|
||||
bun run dev:setup
|
||||
|
||||
# Start the full dev environment
|
||||
bun run dev:watch
|
||||
process-compose up
|
||||
```
|
||||
|
||||
`dev:watch` exits when the VM cache manifest is missing, but setup stays in `dev:setup`.
|
||||
The `process-compose up` command runs the following in order:
|
||||
1. `bun install` — installs dependencies
|
||||
2. `bun --cwd apps/agent codegen` — generates agent code
|
||||
3. `bun --cwd apps/server start` and `bun --cwd apps/agent dev` — starts server and agent in parallel
|
||||
|
||||
### Environment Variables
|
||||
|
||||
|
||||
@@ -74,18 +74,6 @@ const primaryNavItems: NavItem[] = [
|
||||
{ name: 'Settings', to: '/settings/ai', icon: Settings },
|
||||
]
|
||||
|
||||
function isNavItemActive(item: NavItem, pathname: string): boolean {
|
||||
if (item.to === '/settings/ai') {
|
||||
return pathname.startsWith('/settings')
|
||||
}
|
||||
|
||||
if (item.to === '/agents') {
|
||||
return pathname === '/agents' || pathname.startsWith('/agents/')
|
||||
}
|
||||
|
||||
return pathname === item.to
|
||||
}
|
||||
|
||||
export const SidebarNavigation: FC<SidebarNavigationProps> = ({
|
||||
expanded = true,
|
||||
}) => {
|
||||
@@ -102,7 +90,10 @@ export const SidebarNavigation: FC<SidebarNavigationProps> = ({
|
||||
<nav className="space-y-1">
|
||||
{filteredItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = isNavItemActive(item, location.pathname)
|
||||
const isActive =
|
||||
item.to === '/settings/ai'
|
||||
? location.pathname.startsWith('/settings')
|
||||
: location.pathname === item.to
|
||||
|
||||
const navItem = (
|
||||
<NavLink
|
||||
|
||||
@@ -113,22 +113,7 @@ export const App: FC = () => {
|
||||
<Route path="connect-apps" element={<ConnectMCP />} />
|
||||
<Route path="scheduled" element={<ScheduledTasksPage />} />
|
||||
{alphaEnabled ? (
|
||||
<>
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<Route element={<AgentCommandLayout />}>
|
||||
<Route
|
||||
path="agents/:agentId"
|
||||
element={
|
||||
<AgentCommandConversation
|
||||
variant="page"
|
||||
backPath="/agents"
|
||||
agentPathPrefix="/agents"
|
||||
createAgentPath="/agents"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</>
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
) : null}
|
||||
{alphaEnabled ? (
|
||||
<Route path="admin" element={<AdminDashboardPage />} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowLeft, Bot, Home, RotateCcw } from 'lucide-react'
|
||||
import { Bot, Home, RotateCcw } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -11,21 +11,15 @@ import { useAgentConversation } from './useAgentConversation'
|
||||
|
||||
function ConversationHeader({
|
||||
agentName,
|
||||
backLabel,
|
||||
backTarget,
|
||||
status,
|
||||
onNavigateBack,
|
||||
onGoHome,
|
||||
onReset,
|
||||
}: {
|
||||
agentName: string
|
||||
backLabel: string
|
||||
backTarget: 'home' | 'page'
|
||||
status: string
|
||||
onNavigateBack: () => void
|
||||
onGoHome: () => void
|
||||
onReset: () => void
|
||||
}) {
|
||||
const BackIcon = backTarget === 'home' ? Home : ArrowLeft
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-4">
|
||||
@@ -33,11 +27,11 @@ function ConversationHeader({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onNavigateBack}
|
||||
onClick={onGoHome}
|
||||
className="rounded-xl"
|
||||
title={backLabel}
|
||||
title="Back to home"
|
||||
>
|
||||
<BackIcon className="size-4" />
|
||||
<Home className="size-4" />
|
||||
</Button>
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Bot className="size-5" />
|
||||
@@ -91,19 +85,7 @@ function getConversationStatusCopy(
|
||||
return 'Open agent setup to continue'
|
||||
}
|
||||
|
||||
interface AgentCommandConversationProps {
|
||||
variant?: 'command' | 'page'
|
||||
backPath?: string
|
||||
agentPathPrefix?: string
|
||||
createAgentPath?: string
|
||||
}
|
||||
|
||||
export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
variant = 'command',
|
||||
backPath = '/home',
|
||||
agentPathPrefix = '/home/agents',
|
||||
createAgentPath = '/agents',
|
||||
}) => {
|
||||
export const AgentCommandConversation: FC = () => {
|
||||
const { agentId } = useParams<{ agentId: string }>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
@@ -118,8 +100,6 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
useAgentConversation(resolvedAgentId, agentName)
|
||||
const lastTurn = turns[turns.length - 1]
|
||||
const lastTurnPartCount = lastTurn?.parts.length ?? 0
|
||||
const isPageVariant = variant === 'page'
|
||||
const backLabel = isPageVariant ? 'Back to agents' : 'Back to home'
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRedirectHome) return
|
||||
@@ -151,32 +131,18 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
}
|
||||
|
||||
const handleSelectAgent = (entry: AgentEntry) => {
|
||||
navigate(`${agentPathPrefix}/${entry.agentId}`)
|
||||
navigate(`/home/agents/${entry.agentId}`)
|
||||
}
|
||||
|
||||
const statusCopy = getConversationStatusCopy(status?.status, streaming)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden',
|
||||
isPageVariant
|
||||
? 'h-[calc(100vh-7rem)] min-h-[620px]'
|
||||
: 'absolute inset-0',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'fade-in slide-in-from-bottom-5 flex h-full w-full animate-in flex-col gap-3 duration-300',
|
||||
isPageVariant ? 'mx-auto' : 'mx-auto max-w-3xl px-4 pt-4 pb-2',
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="fade-in slide-in-from-bottom-5 mx-auto flex h-full w-full max-w-3xl animate-in flex-col gap-3 px-4 pt-4 pb-2 duration-300">
|
||||
<ConversationHeader
|
||||
agentName={agentName}
|
||||
backLabel={backLabel}
|
||||
backTarget={isPageVariant ? 'page' : 'home'}
|
||||
status={statusCopy}
|
||||
onNavigateBack={() => navigate(backPath)}
|
||||
onGoHome={() => navigate('/home')}
|
||||
onReset={resetConversation}
|
||||
/>
|
||||
|
||||
@@ -215,7 +181,7 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
onSend={(text) => {
|
||||
void send(text)
|
||||
}}
|
||||
onCreateAgent={() => navigate(createAgentPath)}
|
||||
onCreateAgent={() => navigate('/agents')}
|
||||
streaming={streaming}
|
||||
disabled={status?.status !== 'running'}
|
||||
status={status?.status}
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Send,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from '@/components/ai-elements/message'
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { consumeSSEStream } from '@/lib/sse'
|
||||
import {
|
||||
buildChatHistoryFromTurns,
|
||||
chatWithAgent,
|
||||
type OpenClawStreamEvent,
|
||||
} from './useOpenClaw'
|
||||
|
||||
interface ToolEntry {
|
||||
id: string
|
||||
name: string
|
||||
status: 'running' | 'completed' | 'error'
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
type AssistantPart =
|
||||
| { kind: 'thinking'; text: string; done: boolean }
|
||||
| { kind: 'tool-batch'; tools: ToolEntry[] }
|
||||
| { kind: 'text'; text: string }
|
||||
|
||||
interface ChatTurn {
|
||||
id: string
|
||||
userText: string
|
||||
parts: AssistantPart[]
|
||||
done: boolean
|
||||
}
|
||||
|
||||
interface AgentChatProps {
|
||||
agentId: string
|
||||
agentName: string
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export const AgentChat: FC<AgentChatProps> = ({
|
||||
agentId,
|
||||
agentName,
|
||||
onBack,
|
||||
}) => {
|
||||
const [turns, setTurns] = useState<ChatTurn[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const sessionKeyRef = useRef(crypto.randomUUID())
|
||||
const streamAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const textAccRef = useRef('')
|
||||
const thinkAccRef = useRef('')
|
||||
|
||||
const scrollToBottom = () => {
|
||||
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on every turns change
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [turns])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamAbortRef.current?.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateCurrentTurnParts = (
|
||||
updater: (parts: AssistantPart[]) => AssistantPart[],
|
||||
) => {
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
|
||||
})
|
||||
}
|
||||
|
||||
const processStreamEvent = (event: OpenClawStreamEvent) => {
|
||||
switch (event.type) {
|
||||
case 'text-delta': {
|
||||
const delta = (event.data.text as string) ?? ''
|
||||
textAccRef.current += delta
|
||||
const text = textAccRef.current
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const last = parts[parts.length - 1]
|
||||
if (last?.kind === 'text') {
|
||||
return [...parts.slice(0, -1), { ...last, text }]
|
||||
}
|
||||
return [...parts, { kind: 'text', text }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'thinking': {
|
||||
const delta = (event.data.text as string) ?? ''
|
||||
thinkAccRef.current += delta
|
||||
const text = thinkAccRef.current
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
|
||||
if (idx >= 0) {
|
||||
return [
|
||||
...parts.slice(0, idx),
|
||||
{ ...parts[idx], text, done: false },
|
||||
...parts.slice(idx + 1),
|
||||
]
|
||||
}
|
||||
return [...parts, { kind: 'thinking', text, done: false }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-start': {
|
||||
const tool: ToolEntry = {
|
||||
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
|
||||
name: (event.data.toolName as string) ?? 'unknown',
|
||||
status: 'running',
|
||||
}
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const last = parts[parts.length - 1]
|
||||
if (last?.kind === 'tool-batch') {
|
||||
return [
|
||||
...parts.slice(0, -1),
|
||||
{ ...last, tools: [...last.tools, tool] },
|
||||
]
|
||||
}
|
||||
return [...parts, { kind: 'tool-batch', tools: [tool] }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-end': {
|
||||
const toolId = event.data.toolCallId as string
|
||||
const status =
|
||||
(event.data.status as string) === 'error' ? 'error' : 'completed'
|
||||
const durationMs = event.data.durationMs as number | undefined
|
||||
updateCurrentTurnParts((parts) => {
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const part = parts[i]
|
||||
if (
|
||||
part.kind === 'tool-batch' &&
|
||||
part.tools.some((t) => t.id === toolId)
|
||||
) {
|
||||
const updatedTools = part.tools.map((t) =>
|
||||
t.id === toolId
|
||||
? {
|
||||
...t,
|
||||
status: status as ToolEntry['status'],
|
||||
durationMs,
|
||||
}
|
||||
: t,
|
||||
)
|
||||
return [
|
||||
...parts.slice(0, i),
|
||||
{ ...part, tools: updatedTools },
|
||||
...parts.slice(i + 1),
|
||||
]
|
||||
}
|
||||
}
|
||||
return parts
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'done': {
|
||||
updateCurrentTurnParts((parts) =>
|
||||
parts.map((part) =>
|
||||
part.kind === 'thinking' ? { ...part, done: true } : part,
|
||||
),
|
||||
)
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
return [...prev.slice(0, -1), { ...last, done: true }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const msg =
|
||||
(event.data.message as string) ??
|
||||
(event.data.error as string) ??
|
||||
'Unknown error'
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${msg}` },
|
||||
])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || streaming) return
|
||||
const history = buildChatHistoryFromTurns(turns)
|
||||
|
||||
const turn: ChatTurn = {
|
||||
id: crypto.randomUUID(),
|
||||
userText: text,
|
||||
parts: [],
|
||||
done: false,
|
||||
}
|
||||
setTurns((prev) => [...prev, turn])
|
||||
setInput('')
|
||||
setStreaming(true)
|
||||
|
||||
textAccRef.current = ''
|
||||
thinkAccRef.current = ''
|
||||
const abortController = new AbortController()
|
||||
streamAbortRef.current = abortController
|
||||
|
||||
try {
|
||||
const response = await chatWithAgent(
|
||||
agentId,
|
||||
text,
|
||||
sessionKeyRef.current,
|
||||
history,
|
||||
abortController.signal,
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text()
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${err}` },
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
await consumeSSEStream(
|
||||
response,
|
||||
processStreamEvent,
|
||||
abortController.signal,
|
||||
)
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) return
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${msg}` },
|
||||
])
|
||||
} finally {
|
||||
if (streamAbortRef.current === abortController) {
|
||||
streamAbortRef.current = null
|
||||
}
|
||||
setStreaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<h2 className="font-semibold text-lg">{agentName}</h2>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{turns.map((turn) => (
|
||||
<div key={turn.id} className="space-y-3">
|
||||
{/* User message */}
|
||||
<Message from="user">
|
||||
<MessageContent>
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm">
|
||||
{turn.userText}
|
||||
</pre>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
|
||||
{/* Assistant response — all parts grouped */}
|
||||
{turn.parts.length > 0 && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
{turn.parts.map((part, i) => {
|
||||
const key = `${turn.id}-part-${i}`
|
||||
|
||||
switch (part.kind) {
|
||||
case 'thinking':
|
||||
return (
|
||||
<Reasoning
|
||||
key={key}
|
||||
className="w-full"
|
||||
isStreaming={!part.done}
|
||||
defaultOpen={!part.done}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{part.text}</ReasoningContent>
|
||||
</Reasoning>
|
||||
)
|
||||
|
||||
case 'tool-batch':
|
||||
return (
|
||||
<div key={key} className="w-full space-y-1">
|
||||
{part.tools.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
{tool.status === 'running' && (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{tool.status === 'completed' && (
|
||||
<CheckCircle2 className="size-3.5 text-green-500" />
|
||||
)}
|
||||
{tool.status === 'error' && (
|
||||
<XCircle className="size-3.5 text-destructive" />
|
||||
)}
|
||||
<span className="font-mono text-xs">
|
||||
{tool.name}
|
||||
</span>
|
||||
{tool.durationMs != null && (
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{(tool.durationMs / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<MessageResponse key={key}>
|
||||
{part.text}
|
||||
</MessageResponse>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{/* Streaming indicator when waiting for first part */}
|
||||
{!turn.done && turn.parts.length === 0 && streaming && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder="Send a message..."
|
||||
className="min-h-[44px] resize-none"
|
||||
rows={1}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || streaming}
|
||||
size="icon"
|
||||
>
|
||||
{streaming ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
@@ -13,7 +15,6 @@ import {
|
||||
Wrench,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
||||
import { AgentChat } from './AgentChat'
|
||||
import { AgentTerminal } from './AgentTerminal'
|
||||
import { getOpenClawSupportedProviders } from './openclaw-supported-providers'
|
||||
import {
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
useOpenClawAgents,
|
||||
useOpenClawMutations,
|
||||
useOpenClawStatus,
|
||||
usePodmanOverrides,
|
||||
} from './useOpenClaw'
|
||||
|
||||
const LIFECYCLE_BANNER_COPY: Record<GatewayLifecycleAction, string> = {
|
||||
@@ -235,366 +238,123 @@ const ProviderSelector: FC<ProviderSelectorProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
interface AgentsPageHeaderProps {
|
||||
actionInProgress: boolean
|
||||
canManageAgents: boolean
|
||||
controlPlaneBusy: boolean
|
||||
reconnecting: boolean
|
||||
status: OpenClawStatus | null
|
||||
onCreateAgent: () => void
|
||||
onOpenTerminal: () => void
|
||||
onReconnect: () => void
|
||||
onRestart: () => void
|
||||
onStop: () => void
|
||||
}
|
||||
const PodmanOverridesCard: FC = () => {
|
||||
const { overrides, loading, saving, error, saveOverrides, clearOverrides } =
|
||||
usePodmanOverrides()
|
||||
|
||||
const AgentsPageHeader: FC<AgentsPageHeaderProps> = ({
|
||||
actionInProgress,
|
||||
canManageAgents,
|
||||
controlPlaneBusy,
|
||||
reconnecting,
|
||||
status,
|
||||
onCreateAgent,
|
||||
onOpenTerminal,
|
||||
onReconnect,
|
||||
onRestart,
|
||||
onStop,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-bold text-2xl">Agents</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
OpenClaw agents running in a local container
|
||||
</p>
|
||||
</div>
|
||||
const [value, setValue] = useState('')
|
||||
const [touched, setTouched] = useState(false)
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
|
||||
{status && (
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={status.status} />
|
||||
{status.status !== 'uninitialized' && (
|
||||
<ControlPlaneBadge status={status.controlPlaneStatus} />
|
||||
)}
|
||||
useEffect(() => {
|
||||
if (!touched && overrides) setValue(overrides.podmanPath ?? '')
|
||||
}, [overrides, touched])
|
||||
|
||||
{status.status === 'running' && (
|
||||
<>
|
||||
{status.controlPlaneStatus !== 'connected' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onReconnect}
|
||||
disabled={actionInProgress || controlPlaneBusy}
|
||||
>
|
||||
{reconnecting ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
)}
|
||||
Retry Connection
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRestart}
|
||||
disabled={actionInProgress}
|
||||
title="Restart gateway"
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onStop}
|
||||
disabled={actionInProgress}
|
||||
title="Stop gateway"
|
||||
>
|
||||
<Square className="size-4" />
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onOpenTerminal}>
|
||||
<TerminalSquare className="mr-1 size-4" />
|
||||
Terminal
|
||||
</Button>
|
||||
<Button onClick={onCreateAgent} disabled={!canManageAgents}>
|
||||
<Plus className="mr-1 size-4" />
|
||||
New Agent
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
function LifecycleAlert({ message }: { message: string }) {
|
||||
return (
|
||||
<Alert>
|
||||
<Loader2 className="animate-spin" />
|
||||
<AlertTitle>{message}</AlertTitle>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
function InlineErrorAlert({
|
||||
message,
|
||||
onDismiss,
|
||||
}: {
|
||||
message: string
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle />
|
||||
<AlertTitle>OpenClaw action failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>{message}</p>
|
||||
<div className="mt-2">
|
||||
<Button variant="outline" size="sm" onClick={onDismiss}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
interface ControlPlaneAlertProps {
|
||||
actionInProgress: boolean
|
||||
controlPlaneBusy: boolean
|
||||
controlPlaneCopy: ReturnType<typeof getControlPlaneCopy>
|
||||
reconnecting: boolean
|
||||
recoveryDetail: string | null
|
||||
status: OpenClawStatus
|
||||
onReconnect: () => void
|
||||
onRestart: () => void
|
||||
}
|
||||
|
||||
const ControlPlaneAlert: FC<ControlPlaneAlertProps> = ({
|
||||
actionInProgress,
|
||||
controlPlaneBusy,
|
||||
controlPlaneCopy,
|
||||
reconnecting,
|
||||
recoveryDetail,
|
||||
status,
|
||||
onReconnect,
|
||||
onRestart,
|
||||
}) => (
|
||||
<Alert
|
||||
variant={status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'}
|
||||
>
|
||||
{status.controlPlaneStatus === 'failed' ? (
|
||||
<ShieldAlert />
|
||||
) : status.controlPlaneStatus === 'recovering' ? (
|
||||
<Wrench />
|
||||
) : (
|
||||
<WifiOff />
|
||||
)}
|
||||
<AlertTitle>{controlPlaneCopy.title}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>{controlPlaneCopy.description}</p>
|
||||
{recoveryDetail && <p>{recoveryDetail}</p>}
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onReconnect}
|
||||
disabled={actionInProgress || controlPlaneBusy}
|
||||
>
|
||||
{reconnecting ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
)}
|
||||
Retry Connection
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRestart}
|
||||
disabled={actionInProgress}
|
||||
>
|
||||
Restart Gateway
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
|
||||
interface GatewayStateCardsProps {
|
||||
actionInProgress: boolean
|
||||
status: OpenClawStatus | null
|
||||
onOpenSetup: () => void
|
||||
onRestart: () => void
|
||||
onStart: () => void
|
||||
}
|
||||
|
||||
const GatewayStateCards: FC<GatewayStateCardsProps> = ({
|
||||
actionInProgress,
|
||||
status,
|
||||
onOpenSetup,
|
||||
onRestart,
|
||||
onStart,
|
||||
}) => (
|
||||
<>
|
||||
{status?.status === 'uninitialized' && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<Cpu className="size-12 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-lg">Set Up OpenClaw</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{status.podmanAvailable
|
||||
? 'Create a local BrowserOS VM to run autonomous agents with full tool access.'
|
||||
: 'BrowserOS VM runtime is unavailable on this system.'}
|
||||
</p>
|
||||
</div>
|
||||
{status.podmanAvailable && (
|
||||
<Button onClick={onOpenSetup}>Set Up Now</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{status?.status === 'stopped' && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<Cpu className="size-12 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-lg">Gateway Stopped</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The OpenClaw gateway is not running.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onStart} disabled={actionInProgress}>
|
||||
Start Gateway
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{status?.status === 'error' && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<AlertCircle className="size-12 text-destructive" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-lg">Gateway Error</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{status.error ?? status.lastGatewayError}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onStart} disabled={actionInProgress}>
|
||||
Start Gateway
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRestart}
|
||||
disabled={actionInProgress}
|
||||
>
|
||||
Restart Gateway
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
interface RunningAgentsSectionProps {
|
||||
agents: AgentEntry[]
|
||||
agentsLoading: boolean
|
||||
canManageAgents: boolean
|
||||
deleting: boolean
|
||||
status: OpenClawStatus | null
|
||||
onChatAgent: (agentId: string) => void
|
||||
onCreateAgent: () => void
|
||||
onDeleteAgent: (agentId: string) => void
|
||||
}
|
||||
|
||||
const RunningAgentsSection: FC<RunningAgentsSectionProps> = ({
|
||||
agents,
|
||||
agentsLoading,
|
||||
canManageAgents,
|
||||
deleting,
|
||||
status,
|
||||
onChatAgent,
|
||||
onCreateAgent,
|
||||
onDeleteAgent,
|
||||
}) => {
|
||||
if (status?.status !== 'running') return null
|
||||
|
||||
if (agentsLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-8">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No agents yet. Create one to get started.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCreateAgent}
|
||||
disabled={!canManageAgents}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
Create Agent
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
const handleClear = async () => {
|
||||
setLocalError(null)
|
||||
try {
|
||||
await clearOverrides()
|
||||
setValue('')
|
||||
setTouched(false)
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
const hasOverride = !!overrides?.podmanPath
|
||||
const effective = overrides?.effectivePodmanPath ?? null
|
||||
const inlineErrorMessage = localError ?? error?.message ?? null
|
||||
|
||||
const body = (
|
||||
<div className="space-y-3">
|
||||
{agents.map((agent) => (
|
||||
<Card key={agent.agentId}>
|
||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Cpu className="size-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">{agent.name}</CardTitle>
|
||||
</div>
|
||||
<p className="font-mono text-muted-foreground text-xs">
|
||||
{agent.workspace}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onChatAgent(agent.agentId)}
|
||||
disabled={!canManageAgents}
|
||||
>
|
||||
<MessageSquare className="mr-1 size-4" />
|
||||
Chat
|
||||
</Button>
|
||||
{agent.agentId !== 'main' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDeleteAgent(agent.agentId)}
|
||||
disabled={!canManageAgents || deleting}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
<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 navigate = useNavigate()
|
||||
const {
|
||||
status,
|
||||
loading: statusLoading,
|
||||
@@ -630,6 +390,7 @@ export const AgentsPage: FC = () => {
|
||||
const [newName, setNewName] = useState('')
|
||||
const [createProviderId, setCreateProviderId] = useState('')
|
||||
|
||||
const [chatAgent, setChatAgent] = useState<AgentEntry | null>(null)
|
||||
const [showTerminal, setShowTerminal] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@@ -699,7 +460,7 @@ export const AgentsPage: FC = () => {
|
||||
const recoveryDetail = status ? getRecoveryDetail(status) : null
|
||||
const controlPlaneCopy = status
|
||||
? getControlPlaneCopy(status.controlPlaneStatus)
|
||||
: FALLBACK_CONTROL_PLANE_COPY
|
||||
: null
|
||||
|
||||
const runWithErrorHandling = async (fn: () => Promise<unknown>) => {
|
||||
setError(null)
|
||||
@@ -782,6 +543,16 @@ export const AgentsPage: FC = () => {
|
||||
return <AgentTerminal onBack={() => setShowTerminal(false)} />
|
||||
}
|
||||
|
||||
if (chatAgent) {
|
||||
return (
|
||||
<AgentChat
|
||||
agentId={chatAgent.agentId}
|
||||
agentName={chatAgent.name}
|
||||
onBack={() => setChatAgent(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (statusLoading && !status) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@@ -792,61 +563,274 @@ export const AgentsPage: FC = () => {
|
||||
|
||||
return (
|
||||
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
|
||||
<AgentsPageHeader
|
||||
actionInProgress={actionInProgress}
|
||||
canManageAgents={canManageAgents}
|
||||
controlPlaneBusy={gatewayUiState.controlPlaneBusy}
|
||||
reconnecting={reconnecting}
|
||||
status={status}
|
||||
onCreateAgent={() => setCreateOpen(true)}
|
||||
onOpenTerminal={() => setShowTerminal(true)}
|
||||
onReconnect={handleReconnect}
|
||||
onRestart={handleRestart}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-bold text-2xl">Agents</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
OpenClaw agents running in a local container
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{lifecycleBanner && <LifecycleAlert message={lifecycleBanner} />}
|
||||
{status && (
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={status.status} />
|
||||
{status.status !== 'uninitialized' && (
|
||||
<ControlPlaneBadge status={status.controlPlaneStatus} />
|
||||
)}
|
||||
|
||||
{status.status === 'running' && (
|
||||
<>
|
||||
{status.controlPlaneStatus !== 'connected' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReconnect}
|
||||
disabled={
|
||||
actionInProgress || gatewayUiState.controlPlaneBusy
|
||||
}
|
||||
>
|
||||
{reconnecting ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
)}
|
||||
Retry Connection
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRestart}
|
||||
disabled={actionInProgress}
|
||||
title="Restart gateway"
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleStop}
|
||||
disabled={actionInProgress}
|
||||
title="Stop gateway"
|
||||
>
|
||||
<Square className="size-4" />
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowTerminal(true)}>
|
||||
<TerminalSquare className="mr-1 size-4" />
|
||||
Terminal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={!canManageAgents}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
New Agent
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lifecycleBanner && (
|
||||
<Alert>
|
||||
<Loader2 className="animate-spin" />
|
||||
<AlertTitle>{lifecycleBanner}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{inlineError && (
|
||||
<InlineErrorAlert
|
||||
message={inlineError}
|
||||
onDismiss={() => setError(null)}
|
||||
/>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle />
|
||||
<AlertTitle>OpenClaw action failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>{inlineError}</p>
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status && showControlPlaneDegraded && (
|
||||
<ControlPlaneAlert
|
||||
actionInProgress={actionInProgress}
|
||||
controlPlaneBusy={gatewayUiState.controlPlaneBusy}
|
||||
controlPlaneCopy={controlPlaneCopy}
|
||||
reconnecting={reconnecting}
|
||||
recoveryDetail={recoveryDetail}
|
||||
status={status}
|
||||
onReconnect={handleReconnect}
|
||||
onRestart={handleRestart}
|
||||
/>
|
||||
<Alert
|
||||
variant={
|
||||
status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'
|
||||
}
|
||||
>
|
||||
{status.controlPlaneStatus === 'failed' ? (
|
||||
<ShieldAlert />
|
||||
) : status.controlPlaneStatus === 'recovering' ? (
|
||||
<Wrench />
|
||||
) : (
|
||||
<WifiOff />
|
||||
)}
|
||||
<AlertTitle>{controlPlaneCopy?.title}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>{controlPlaneCopy?.description}</p>
|
||||
{recoveryDetail && <p>{recoveryDetail}</p>}
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReconnect}
|
||||
disabled={actionInProgress || gatewayUiState.controlPlaneBusy}
|
||||
>
|
||||
{reconnecting ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
)}
|
||||
Retry Connection
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRestart}
|
||||
disabled={actionInProgress}
|
||||
>
|
||||
Restart Gateway
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<GatewayStateCards
|
||||
actionInProgress={actionInProgress}
|
||||
status={status}
|
||||
onOpenSetup={() => setSetupOpen(true)}
|
||||
onRestart={handleRestart}
|
||||
onStart={handleStart}
|
||||
/>
|
||||
{status?.status === 'uninitialized' && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<Cpu className="size-12 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-lg">Set Up OpenClaw</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{status.podmanAvailable
|
||||
? 'Create a local container to run autonomous agents with full tool access.'
|
||||
: 'Podman is required to run OpenClaw agents. Install Podman first.'}
|
||||
</p>
|
||||
</div>
|
||||
{status.podmanAvailable && (
|
||||
<Button onClick={() => setSetupOpen(true)}>Set Up Now</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<RunningAgentsSection
|
||||
agents={agents}
|
||||
agentsLoading={agentsLoading}
|
||||
canManageAgents={canManageAgents}
|
||||
deleting={deleting}
|
||||
status={status}
|
||||
onChatAgent={(agentId) => navigate(`/agents/${agentId}`)}
|
||||
onCreateAgent={() => setCreateOpen(true)}
|
||||
onDeleteAgent={(agentId) => {
|
||||
void handleDelete(agentId)
|
||||
}}
|
||||
/>
|
||||
{status?.status === 'stopped' && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<Cpu className="size-12 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-lg">Gateway Stopped</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The OpenClaw gateway is not running.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleStart} disabled={actionInProgress}>
|
||||
Start Gateway
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{status?.status === 'error' && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<AlertCircle className="size-12 text-destructive" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-lg">Gateway Error</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{status.error ?? status.lastGatewayError}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleStart} disabled={actionInProgress}>
|
||||
Start Gateway
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionInProgress}
|
||||
>
|
||||
Restart Gateway
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{status?.status === 'running' && (
|
||||
<div className="space-y-3">
|
||||
{agentsLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-8">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No agents yet. Create one to get started.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={!canManageAgents}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
Create Agent
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
agents.map((agent) => (
|
||||
<Card key={agent.agentId}>
|
||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Cpu className="size-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">
|
||||
{agent.name}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<p className="font-mono text-muted-foreground text-xs">
|
||||
{agent.workspace}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setChatAgent(agent)}
|
||||
disabled={!canManageAgents}
|
||||
>
|
||||
<MessageSquare className="mr-1 size-4" />
|
||||
Chat
|
||||
</Button>
|
||||
{agent.agentId !== 'main' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(agent.agentId)}
|
||||
disabled={!canManageAgents || deleting}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PodmanOverridesCard />
|
||||
|
||||
<Dialog open={setupOpen} onOpenChange={setSetupOpen}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -59,8 +59,14 @@ export function getModelDisplayName(model: unknown): string | undefined {
|
||||
export const OPENCLAW_QUERY_KEYS = {
|
||||
status: 'openclaw-status',
|
||||
agents: 'openclaw-agents',
|
||||
podmanOverrides: 'openclaw-podman-overrides',
|
||||
} as const
|
||||
|
||||
export interface PodmanOverrides {
|
||||
podmanPath: string | null
|
||||
effectivePodmanPath: string
|
||||
}
|
||||
|
||||
export type GatewayLifecycleAction =
|
||||
| 'setup'
|
||||
| 'start'
|
||||
@@ -256,6 +262,50 @@ export function useOpenClawMutations() {
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpenClawStreamEvent {
|
||||
type:
|
||||
| 'text-delta'
|
||||
|
||||
@@ -26,5 +26,9 @@ LOG_LEVEL=info
|
||||
# Debug — captures every LLM call to .devtools/generations.json (view with `npx @ai-sdk/devtools`)
|
||||
# BROWSEROS_AI_SDK_DEVTOOLS=true
|
||||
|
||||
# Podman machine (macOS/Windows VM). No-op on Linux.
|
||||
BROWSEROS_PODMAN_CPUS=4
|
||||
BROWSEROS_PODMAN_MEMORY_MB=4096
|
||||
|
||||
# Testing
|
||||
BROWSEROS_TEST_HEADLESS=false
|
||||
|
||||
@@ -142,7 +142,7 @@ cp .env.example .env.development
|
||||
bun run start
|
||||
```
|
||||
|
||||
See the [agent monorepo README](../../README.md) for full environment variable reference and `dev:watch` setup.
|
||||
See the [agent monorepo README](../../README.md) for full environment variable reference and `process-compose` setup.
|
||||
|
||||
### Testing
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* Thin layer delegating to OpenClawService.
|
||||
*/
|
||||
|
||||
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'
|
||||
@@ -17,7 +19,6 @@ import {
|
||||
OpenClawAgentNotFoundError,
|
||||
OpenClawInvalidAgentNameError,
|
||||
OpenClawProtectedAgentError,
|
||||
OpenClawSessionNotFoundError,
|
||||
} from '../services/openclaw/errors'
|
||||
import { isUnsupportedOpenClawProviderError } from '../services/openclaw/openclaw-provider-map'
|
||||
import { getOpenClawService } from '../services/openclaw/openclaw-service'
|
||||
@@ -29,6 +30,27 @@ function getCreateAgentValidationError(body: { name?: string }): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
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() {
|
||||
return new Hono()
|
||||
.get('/status', async (c) => {
|
||||
@@ -80,7 +102,7 @@ export function createOpenClawRoutes() {
|
||||
if (isUnsupportedOpenClawProviderError(err)) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
if (message.includes('VM runtime is not available')) {
|
||||
if (message.includes('Podman is not available')) {
|
||||
return c.json({ error: message }, 503)
|
||||
}
|
||||
return c.json({ error: message }, 500)
|
||||
@@ -322,61 +344,6 @@ export function createOpenClawRoutes() {
|
||||
}
|
||||
})
|
||||
|
||||
.get('/session/:key/history', async (c) => {
|
||||
const key = c.req.param('key')
|
||||
const limitRaw = c.req.query('limit')
|
||||
const cursor = c.req.query('cursor')
|
||||
const limitParsed =
|
||||
limitRaw !== undefined ? Number.parseInt(limitRaw, 10) : Number.NaN
|
||||
const limit = Number.isFinite(limitParsed) ? limitParsed : undefined
|
||||
const wantsStream = (c.req.header('accept') ?? '').includes(
|
||||
'text/event-stream',
|
||||
)
|
||||
|
||||
try {
|
||||
if (!wantsStream) {
|
||||
const history = await getOpenClawService().getSessionHistory(key, {
|
||||
limit,
|
||||
cursor,
|
||||
})
|
||||
return c.json(history)
|
||||
}
|
||||
|
||||
const eventStream = await getOpenClawService().streamSessionHistory(
|
||||
key,
|
||||
{ limit, cursor, signal: c.req.raw.signal },
|
||||
)
|
||||
|
||||
c.header('Content-Type', 'text/event-stream')
|
||||
c.header('Cache-Control', 'no-cache')
|
||||
c.header('X-Session-Key', key)
|
||||
|
||||
return stream(c, async (s) => {
|
||||
const reader = eventStream.getReader()
|
||||
const encoder = new TextEncoder()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
await s.write(
|
||||
encoder.encode(
|
||||
`event: ${value.type}\ndata: ${JSON.stringify(value.data)}\n\n`,
|
||||
),
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel()
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof OpenClawSessionNotFoundError) {
|
||||
return c.json({ error: err.message }, 404)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.get('/logs', async (c) => {
|
||||
try {
|
||||
const logs = await getOpenClawService().getLogs()
|
||||
@@ -416,4 +383,37 @@ export function createOpenClawRoutes() {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,9 +16,7 @@ export const TERMINAL_WS_PATH = '/terminal/ws'
|
||||
|
||||
interface TerminalRouteDeps {
|
||||
containerName: string
|
||||
limaHome: string
|
||||
limactlPath: string
|
||||
vmName: string
|
||||
podmanPath: string
|
||||
}
|
||||
|
||||
function safeSend(ws: { send(data: string): void }, data: string): void {
|
||||
@@ -47,9 +45,7 @@ function createSocketEvents(deps: TerminalRouteDeps) {
|
||||
try {
|
||||
session = createTerminalSession({
|
||||
containerName: deps.containerName,
|
||||
limaHome: deps.limaHome,
|
||||
limactlPath: deps.limactlPath,
|
||||
vmName: deps.vmName,
|
||||
podmanPath: deps.podmanPath,
|
||||
workingDir: TERMINAL_HOME_DIR,
|
||||
onOutput(data) {
|
||||
sendOutput(ws, data)
|
||||
|
||||
@@ -22,7 +22,6 @@ import { initializeOAuth } from '../lib/clients/oauth'
|
||||
import { getDb } from '../lib/db'
|
||||
import { logger } from '../lib/logger'
|
||||
import { Sentry } from '../lib/sentry'
|
||||
import { getLimaHomeDir, resolveBundledLimactl, VM_NAME } from '../lib/vm'
|
||||
import { createAclRoutes } from './routes/acl'
|
||||
import { createChatRoutes } from './routes/chat'
|
||||
import { createCreditsRoutes } from './routes/credits'
|
||||
@@ -46,6 +45,7 @@ import {
|
||||
connectKlavisInBackground,
|
||||
type KlavisProxyRef,
|
||||
} from './services/klavis/strata-proxy'
|
||||
import { getPodmanRuntime } from './services/openclaw/podman-runtime'
|
||||
import type { Env, HttpServerConfig } from './types'
|
||||
import { defaultCorsConfig } from './utils/cors'
|
||||
import { requireTrustedAppOrigin } from './utils/request-auth'
|
||||
@@ -114,9 +114,7 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
'/',
|
||||
createTerminalRoutes({
|
||||
containerName: OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
limaHome: getLimaHomeDir(),
|
||||
limactlPath: resolveBundledLimactl(resourcesDir),
|
||||
vmName: VM_NAME,
|
||||
podmanPath: getPodmanRuntime().getPodmanPath(),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { cpSync, existsSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { getBrowserosDir } from '../../../lib/browseros-dir'
|
||||
import { ContainerCli, ImageLoader } from '../../../lib/container'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import {
|
||||
detectArch,
|
||||
getLimaHomeDir,
|
||||
resolveBundledLimactl,
|
||||
resolveBundledLimaTemplate,
|
||||
VM_NAME,
|
||||
VmRuntime,
|
||||
} from '../../../lib/vm'
|
||||
import { readCachedManifest } from '../../../lib/vm/manifest'
|
||||
import { VM_TELEMETRY_EVENTS } from '../../../lib/vm/telemetry'
|
||||
import { ContainerRuntime } from './container-runtime'
|
||||
|
||||
const UNSUPPORTED_PLATFORM_MESSAGE =
|
||||
'browseros-vm currently supports macOS only; see the Linux/Windows tracking issue'
|
||||
|
||||
export interface ContainerRuntimeFactoryInput {
|
||||
resourcesDir?: string
|
||||
projectDir: string
|
||||
browserosRoot?: string
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
export function buildContainerRuntime(
|
||||
input: ContainerRuntimeFactoryInput,
|
||||
): ContainerRuntime {
|
||||
const platform = input.platform ?? process.platform
|
||||
if (platform !== 'darwin') {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return new UnsupportedPlatformTestRuntime(input.projectDir)
|
||||
}
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
const browserosRoot = input.browserosRoot ?? getBrowserosDir()
|
||||
if (input.resourcesDir) {
|
||||
migrateLegacyOpenClawDirSync(browserosRoot)
|
||||
}
|
||||
|
||||
const limactlPath = input.resourcesDir
|
||||
? resolveBundledLimactl(input.resourcesDir)
|
||||
: 'limactl'
|
||||
const limaHome = getLimaHomeDir(browserosRoot)
|
||||
const vm = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
templatePath: input.resourcesDir
|
||||
? resolveBundledLimaTemplate(input.resourcesDir)
|
||||
: undefined,
|
||||
browserosRoot,
|
||||
})
|
||||
const shell = new ContainerCli({ limactlPath, limaHome, vmName: VM_NAME })
|
||||
const loader = new DeferredImageLoader(shell, browserosRoot)
|
||||
|
||||
return new ContainerRuntime({
|
||||
vm,
|
||||
shell,
|
||||
loader,
|
||||
projectDir: input.projectDir,
|
||||
})
|
||||
}
|
||||
|
||||
export async function migrateLegacyOpenClawDir(
|
||||
browserosRoot = getBrowserosDir(),
|
||||
): Promise<void> {
|
||||
migrateLegacyOpenClawDirSync(browserosRoot)
|
||||
}
|
||||
|
||||
function migrateLegacyOpenClawDirSync(browserosRoot = getBrowserosDir()): void {
|
||||
const legacyDir = join(browserosRoot, 'openclaw')
|
||||
const nextDir = join(browserosRoot, 'vm', 'openclaw')
|
||||
if (!existsSync(legacyDir)) return
|
||||
if (existsSync(nextDir)) {
|
||||
logger.warn('OpenClaw legacy and VM state directories both exist', {
|
||||
legacyDir,
|
||||
nextDir,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
mkdirSync(dirname(nextDir), { recursive: true })
|
||||
cpSync(legacyDir, nextDir, { recursive: true })
|
||||
logger.info(VM_TELEMETRY_EVENTS.migrationOpenClawMoved, {
|
||||
from: legacyDir,
|
||||
to: nextDir,
|
||||
})
|
||||
}
|
||||
|
||||
class DeferredImageLoader {
|
||||
constructor(
|
||||
private readonly shell: ContainerCli,
|
||||
private readonly browserosRoot: string,
|
||||
) {}
|
||||
|
||||
async ensureImageLoaded(ref: string, onLog?: (msg: string) => void) {
|
||||
const manifest = await readCachedManifest(this.browserosRoot)
|
||||
const loader = new ImageLoader(
|
||||
this.shell,
|
||||
manifest,
|
||||
detectArch(),
|
||||
this.browserosRoot,
|
||||
)
|
||||
await loader.ensureImageLoaded(ref, onLog)
|
||||
}
|
||||
}
|
||||
|
||||
class UnsupportedPlatformTestRuntime extends ContainerRuntime {
|
||||
constructor(projectDir: string) {
|
||||
super({
|
||||
vm: {} as VmRuntime,
|
||||
shell: {} as ContainerCli,
|
||||
loader: { ensureImageLoaded: rejectUnsupportedPlatform },
|
||||
projectDir,
|
||||
})
|
||||
}
|
||||
|
||||
override async ensureReady(): Promise<void> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async isPodmanAvailable(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
override async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
return { initialized: false, running: false }
|
||||
}
|
||||
|
||||
override async pullImage(): Promise<void> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async startGateway(): Promise<void> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async stopGateway(): Promise<void> {}
|
||||
|
||||
override async restartGateway(): Promise<void> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async getGatewayLogs(): Promise<string[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
override async isHealthy(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
override async isReady(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
override async waitForReady(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
override async stopVm(): Promise<void> {}
|
||||
|
||||
override async execInContainer(): Promise<number> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async runGatewaySetupCommand(): Promise<number> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override tailGatewayLogs(): () => void {
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectUnsupportedPlatform(): Promise<never> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
function unsupportedPlatformError(): Error {
|
||||
return new Error(UNSUPPORTED_PLATFORM_MESSAGE)
|
||||
}
|
||||
@@ -2,23 +2,19 @@
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* OpenClaw container lifecycle abstraction over PodmanRuntime.
|
||||
*/
|
||||
|
||||
import {
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import type { ContainerCli, ContainerSpec, LogFn } from '../../../lib/container'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import {
|
||||
GUEST_VM_STATE,
|
||||
hostPathToGuest,
|
||||
type VmRuntime,
|
||||
} from '../../../lib/vm'
|
||||
import type { LogFn, PodmanRuntime } from './podman-runtime'
|
||||
|
||||
const GATEWAY_CONTAINER_HOME = '/home/node'
|
||||
const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
|
||||
const GUEST_OPENCLAW_HOME = `${GUEST_VM_STATE}/openclaw`
|
||||
|
||||
export type GatewayContainerSpec = {
|
||||
image: string
|
||||
@@ -29,63 +25,78 @@ export type GatewayContainerSpec = {
|
||||
timezone: string
|
||||
}
|
||||
|
||||
export interface ContainerRuntimeConfig {
|
||||
vm: VmRuntime
|
||||
shell: ContainerCli
|
||||
loader: { ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void> }
|
||||
projectDir: string
|
||||
}
|
||||
|
||||
export class ContainerRuntime {
|
||||
private readonly vm: VmRuntime
|
||||
private readonly shell: ContainerCli
|
||||
private readonly loader: {
|
||||
ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void>
|
||||
}
|
||||
private readonly projectDir: string
|
||||
|
||||
constructor(config: ContainerRuntimeConfig) {
|
||||
this.vm = config.vm
|
||||
this.shell = config.shell
|
||||
this.loader = config.loader
|
||||
this.projectDir = config.projectDir
|
||||
}
|
||||
constructor(
|
||||
private podman: PodmanRuntime,
|
||||
private projectDir: string,
|
||||
) {}
|
||||
|
||||
async ensureReady(onLog?: LogFn): Promise<void> {
|
||||
logger.info('Ensuring BrowserOS VM runtime readiness')
|
||||
await this.vm.ensureReady(onLog)
|
||||
await this.vm.getDefaultGateway()
|
||||
logger.info('Ensuring Podman runtime readiness')
|
||||
return this.podman.ensureReady(onLog)
|
||||
}
|
||||
|
||||
async isPodmanAvailable(): Promise<boolean> {
|
||||
return true
|
||||
return this.podman.isPodmanAvailable()
|
||||
}
|
||||
|
||||
async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
const running = await this.vm.isReady()
|
||||
return { initialized: running, running }
|
||||
return this.podman.getMachineStatus()
|
||||
}
|
||||
|
||||
async pullImage(image: string, onLog?: LogFn): Promise<void> {
|
||||
await this.loader.ensureImageLoaded(image, onLog)
|
||||
const code = await this.runPodmanCommand(['pull', image], onLog)
|
||||
if (code !== 0) throw new Error(`image pull failed with code ${code}`)
|
||||
}
|
||||
|
||||
async startGateway(
|
||||
input: GatewayContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
await this.removeGatewayContainer(onLog)
|
||||
await this.loader.ensureImageLoaded(input.image, onLog)
|
||||
const container = await this.buildGatewayContainerSpec(input)
|
||||
await this.shell.createContainer(container, onLog)
|
||||
await this.shell.startContainer(container.name)
|
||||
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 stopGateway(onLog?: LogFn): Promise<void> {
|
||||
await this.removeGatewayContainer(onLog)
|
||||
const code = await this.removeGatewayContainer(onLog)
|
||||
if (code !== 0) {
|
||||
throw new Error(`gateway stop failed with code ${code}`)
|
||||
}
|
||||
}
|
||||
|
||||
async restartGateway(
|
||||
@@ -97,8 +108,8 @@ export class ContainerRuntime {
|
||||
|
||||
async getGatewayLogs(tail = 50): Promise<string[]> {
|
||||
const lines: string[] = []
|
||||
await this.shell.runCommand(
|
||||
['logs', '-n', String(tail), OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
await this.runPodmanCommand(
|
||||
['logs', '--tail', String(tail), OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
(line) => lines.push(line),
|
||||
)
|
||||
return lines
|
||||
@@ -129,7 +140,13 @@ export class ContainerRuntime {
|
||||
})
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await this.isReady(hostPort)) return true
|
||||
if (await this.isReady(hostPort)) {
|
||||
logger.info('OpenClaw gateway became ready', {
|
||||
hostPort,
|
||||
waitMs: Date.now() - start,
|
||||
})
|
||||
return true
|
||||
}
|
||||
await Bun.sleep(1000)
|
||||
}
|
||||
logger.error('Timed out waiting for OpenClaw gateway readiness', {
|
||||
@@ -139,12 +156,35 @@ export class ContainerRuntime {
|
||||
return false
|
||||
}
|
||||
|
||||
async stopVm(): Promise<void> {
|
||||
await this.vm.stopVm()
|
||||
/**
|
||||
* Stops the Podman machine only if no non-BrowserOS containers are running.
|
||||
* Prevents killing the user's own Podman workloads.
|
||||
*/
|
||||
async stopMachineIfSafe(): Promise<void> {
|
||||
const status = await this.podman.getMachineStatus()
|
||||
if (!status.running) return
|
||||
|
||||
try {
|
||||
const containers = await this.podman.listRunningContainers()
|
||||
const allOurs = containers.every(
|
||||
(name) => name === OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
)
|
||||
|
||||
if (containers.length === 0 || allOurs) {
|
||||
await this.podman.stopMachine()
|
||||
}
|
||||
} catch {
|
||||
// Best effort — don't stop machine if we can't check
|
||||
}
|
||||
}
|
||||
|
||||
async execInContainer(command: string[], onLog?: LogFn): Promise<number> {
|
||||
return this.shell.exec(OPENCLAW_GATEWAY_CONTAINER_NAME, command, onLog)
|
||||
return this.podman.runCommand(
|
||||
['exec', OPENCLAW_GATEWAY_CONTAINER_NAME, ...command],
|
||||
{
|
||||
onOutput: onLog,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async runGatewaySetupCommand(
|
||||
@@ -153,134 +193,103 @@ export class ContainerRuntime {
|
||||
onLog?: LogFn,
|
||||
): Promise<number> {
|
||||
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
|
||||
await this.shell.removeContainer(setupContainerName, { force: true }, onLog)
|
||||
await this.loader.ensureImageLoaded(spec.image, onLog)
|
||||
await this.runPodmanCommand(
|
||||
['rm', '-f', '--ignore', setupContainerName],
|
||||
onLog,
|
||||
)
|
||||
const setupArgs = command[0] === 'node' ? command.slice(1) : command
|
||||
const createResult = await this.shell.runCommand(
|
||||
return this.runPodmanCommand(
|
||||
[
|
||||
'create',
|
||||
'run',
|
||||
'--rm',
|
||||
'--name',
|
||||
setupContainerName,
|
||||
...(await this.buildGatewayRunArgs(spec)),
|
||||
...this.buildGatewayContainerRuntimeArgs(spec),
|
||||
spec.image,
|
||||
'node',
|
||||
...setupArgs,
|
||||
],
|
||||
onLog,
|
||||
)
|
||||
if (createResult.exitCode !== 0) {
|
||||
await this.shell.removeContainer(
|
||||
setupContainerName,
|
||||
{ force: true },
|
||||
onLog,
|
||||
)
|
||||
return createResult.exitCode
|
||||
}
|
||||
|
||||
try {
|
||||
const startResult = await this.shell.runCommand(
|
||||
['start', '-a', setupContainerName],
|
||||
onLog,
|
||||
)
|
||||
return startResult.exitCode
|
||||
} finally {
|
||||
await this.shell.removeContainer(
|
||||
setupContainerName,
|
||||
{ force: true },
|
||||
onLog,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tailGatewayLogs(onLine: LogFn): () => void {
|
||||
return this.shell.tailLogs(OPENCLAW_GATEWAY_CONTAINER_NAME, onLine)
|
||||
return this.podman.tailContainerLogs(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
onLine,
|
||||
)
|
||||
}
|
||||
|
||||
private async removeGatewayContainer(onLog?: LogFn): Promise<void> {
|
||||
await this.shell.removeContainer(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
{ force: true },
|
||||
private async runPodmanCommand(
|
||||
args: string[],
|
||||
onLog?: LogFn,
|
||||
): Promise<number> {
|
||||
const lines: string[] = []
|
||||
const command = ['podman', ...args].join(' ')
|
||||
logger.info('Running OpenClaw podman command', {
|
||||
command,
|
||||
})
|
||||
const code = await this.podman.runCommand(args, {
|
||||
cwd: this.projectDir,
|
||||
onOutput: (line) => {
|
||||
lines.push(line)
|
||||
onLog?.(line)
|
||||
},
|
||||
})
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error('OpenClaw podman command failed', {
|
||||
command,
|
||||
exitCode: code,
|
||||
output: lines,
|
||||
})
|
||||
} else {
|
||||
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 async buildGatewayContainerSpec(
|
||||
private buildGatewayContainerRuntimeArgs(
|
||||
input: GatewayContainerSpec,
|
||||
): Promise<ContainerSpec> {
|
||||
return {
|
||||
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
image: input.image,
|
||||
restart: 'unless-stopped',
|
||||
ports: [
|
||||
{
|
||||
hostIp: '127.0.0.1',
|
||||
hostPort: input.hostPort,
|
||||
containerPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
},
|
||||
],
|
||||
envFile: this.translateHostPath(input.envFilePath, input.hostHome),
|
||||
env: this.buildGatewayEnv(input),
|
||||
mounts: [{ source: GUEST_OPENCLAW_HOME, target: GATEWAY_CONTAINER_HOME }],
|
||||
addHosts: [await this.hostContainersInternalEntry()],
|
||||
health: {
|
||||
cmd: `curl -sf http://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}/healthz`,
|
||||
interval: '30s',
|
||||
timeout: '10s',
|
||||
retries: 3,
|
||||
},
|
||||
command: [
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'gateway',
|
||||
'--bind',
|
||||
'lan',
|
||||
'--port',
|
||||
String(OPENCLAW_GATEWAY_CONTAINER_PORT),
|
||||
'--allow-unconfigured',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
private async buildGatewayRunArgs(
|
||||
input: GatewayContainerSpec,
|
||||
): Promise<string[]> {
|
||||
const args = [
|
||||
): string[] {
|
||||
return [
|
||||
'--env-file',
|
||||
this.translateHostPath(input.envFilePath, input.hostHome),
|
||||
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',
|
||||
`${GUEST_OPENCLAW_HOME}:${GATEWAY_CONTAINER_HOME}`,
|
||||
]
|
||||
for (const [key, value] of Object.entries(this.buildGatewayEnv(input))) {
|
||||
args.push('-e', `${key}=${value}`)
|
||||
}
|
||||
args.push('--add-host', await this.hostContainersInternalEntry())
|
||||
return args
|
||||
}
|
||||
|
||||
private async hostContainersInternalEntry(): Promise<string> {
|
||||
return `host.containers.internal:${await this.vm.getDefaultGateway()}`
|
||||
}
|
||||
|
||||
private buildGatewayEnv(input: GatewayContainerSpec): Record<string, string> {
|
||||
return {
|
||||
HOME: GATEWAY_CONTAINER_HOME,
|
||||
OPENCLAW_HOME: GATEWAY_CONTAINER_HOME,
|
||||
OPENCLAW_STATE_DIR: GATEWAY_STATE_DIR,
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
NODE_COMPILE_CACHE: '/var/tmp/openclaw-compile-cache',
|
||||
NODE_ENV: 'production',
|
||||
TZ: input.timezone,
|
||||
`${input.hostHome}:${GATEWAY_CONTAINER_HOME}`,
|
||||
'--add-host',
|
||||
'host.containers.internal:host-gateway',
|
||||
...(input.gatewayToken
|
||||
? { OPENCLAW_GATEWAY_TOKEN: input.gatewayToken }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
private translateHostPath(path: string, openclawHostDir: string): string {
|
||||
if (path === openclawHostDir) return GUEST_OPENCLAW_HOME
|
||||
if (path.startsWith(`${openclawHostDir}/`)) {
|
||||
return `${GUEST_OPENCLAW_HOME}${path.slice(openclawHostDir.length)}`
|
||||
}
|
||||
return hostPathToGuest(path)
|
||||
? ['-e', `OPENCLAW_GATEWAY_TOKEN=${input.gatewayToken}`]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,3 @@ export class OpenClawProtectedAgentError extends Error {
|
||||
this.name = 'OpenClawProtectedAgentError'
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenClawSessionNotFoundError extends Error {
|
||||
constructor(public readonly sessionKey: string) {
|
||||
super(`OpenClaw session not found: ${sessionKey}`)
|
||||
this.name = 'OpenClawSessionNotFoundError'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { createParser, type EventSourceMessage } from 'eventsource-parser'
|
||||
import { OpenClawSessionNotFoundError } from './errors'
|
||||
import type { OpenClawStreamEvent } from './openclaw-types'
|
||||
|
||||
export interface OpenClawChatHistoryMessage {
|
||||
@@ -21,42 +20,7 @@ export interface OpenClawChatRequest {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface OpenClawSessionHistoryMessage {
|
||||
role: 'user' | 'assistant' | 'system' | 'tool'
|
||||
content: string
|
||||
messageId?: string
|
||||
messageSeq?: number
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
export interface OpenClawSessionHistory {
|
||||
sessionKey: string
|
||||
messages: OpenClawSessionHistoryMessage[]
|
||||
cursor?: string | null
|
||||
hasMore?: boolean
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
export interface OpenClawSessionHistoryInput {
|
||||
limit?: number
|
||||
cursor?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type OpenClawSessionHistoryEvent =
|
||||
| { type: 'history'; data: OpenClawSessionHistory }
|
||||
| {
|
||||
type: 'message'
|
||||
data: {
|
||||
sessionKey: string
|
||||
message: OpenClawSessionHistoryMessage
|
||||
messageId?: string
|
||||
messageSeq: number
|
||||
}
|
||||
}
|
||||
| { type: 'error'; data: { message: string } }
|
||||
|
||||
export class OpenClawHttpClient {
|
||||
export class OpenClawHttpChatClient {
|
||||
constructor(
|
||||
private readonly hostPort: number,
|
||||
private readonly getToken: () => Promise<string>,
|
||||
@@ -75,46 +39,6 @@ export class OpenClawHttpClient {
|
||||
return createEventStream(body, input.signal)
|
||||
}
|
||||
|
||||
async getSessionHistory(
|
||||
sessionKey: string,
|
||||
input: OpenClawSessionHistoryInput = {},
|
||||
): Promise<OpenClawSessionHistory> {
|
||||
const response = await this.fetchSessionHistory(sessionKey, input, {})
|
||||
return (await response.json()) as OpenClawSessionHistory
|
||||
}
|
||||
|
||||
async streamSessionHistory(
|
||||
sessionKey: string,
|
||||
input: OpenClawSessionHistoryInput = {},
|
||||
): Promise<ReadableStream<OpenClawSessionHistoryEvent>> {
|
||||
const response = await this.fetchSessionHistory(sessionKey, input, {
|
||||
Accept: 'text/event-stream',
|
||||
})
|
||||
const body = response.body
|
||||
if (!body) {
|
||||
throw new Error('OpenClaw session history stream had no body')
|
||||
}
|
||||
return createHistoryEventStream(body, input.signal)
|
||||
}
|
||||
|
||||
async isAuthenticated(): Promise<boolean> {
|
||||
try {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${this.hostPort}/v1/models`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
)
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchChat(input: OpenClawChatRequest): Promise<Response> {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
@@ -147,50 +71,6 @@ export class OpenClawHttpClient {
|
||||
detail || `OpenClaw chat failed with status ${response.status}`,
|
||||
)
|
||||
}
|
||||
|
||||
private async fetchSessionHistory(
|
||||
sessionKey: string,
|
||||
input: OpenClawSessionHistoryInput,
|
||||
extraHeaders: Record<string, string>,
|
||||
): Promise<Response> {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${this.hostPort}${buildHistoryPath(sessionKey, input)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...extraHeaders,
|
||||
},
|
||||
signal: input.signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new OpenClawSessionNotFoundError(sessionKey)
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = await response.text()
|
||||
throw new Error(
|
||||
detail ||
|
||||
`OpenClaw session history failed with status ${response.status}`,
|
||||
)
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
function buildHistoryPath(
|
||||
sessionKey: string,
|
||||
input: OpenClawSessionHistoryInput,
|
||||
): string {
|
||||
const qs = new URLSearchParams()
|
||||
if (input.limit !== undefined) qs.set('limit', String(input.limit))
|
||||
if (input.cursor !== undefined) qs.set('cursor', input.cursor)
|
||||
const suffix = qs.toString()
|
||||
return `/sessions/${encodeURIComponent(sessionKey)}/history${
|
||||
suffix ? `?${suffix}` : ''
|
||||
}`
|
||||
}
|
||||
|
||||
function resolveAgentModel(agentId: string): string {
|
||||
@@ -382,104 +262,3 @@ function parseChunk(data: string): Record<string, unknown> | null {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function createHistoryEventStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
signal?: AbortSignal,
|
||||
): ReadableStream<OpenClawSessionHistoryEvent> {
|
||||
return new ReadableStream<OpenClawSessionHistoryEvent>({
|
||||
start(controller) {
|
||||
void pumpHistoryEvents(body, controller, signal)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function pumpHistoryEvents(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
controller: ReadableStreamDefaultController<OpenClawSessionHistoryEvent>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let closed = false
|
||||
const close = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
controller.close()
|
||||
}
|
||||
const parser = createParser({
|
||||
onEvent(message) {
|
||||
if (closed) return
|
||||
const event = toHistoryEvent(message)
|
||||
if (!event) return
|
||||
controller.enqueue(event)
|
||||
if (event.type === 'error') close()
|
||||
},
|
||||
})
|
||||
|
||||
const onAbort = () => {
|
||||
void reader.cancel().catch(() => {})
|
||||
close()
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
await reader.cancel()
|
||||
close()
|
||||
return
|
||||
}
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
parser.feed(decoder.decode(value, { stream: true }))
|
||||
}
|
||||
} catch (error) {
|
||||
if (!closed) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
data: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
})
|
||||
close()
|
||||
}
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
close()
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
function toHistoryEvent(
|
||||
message: EventSourceMessage,
|
||||
): OpenClawSessionHistoryEvent | null {
|
||||
if (!message.event) return null
|
||||
const payload = parseChunk(message.data)
|
||||
if (!payload) return null
|
||||
if (message.event === 'history') {
|
||||
return {
|
||||
type: 'history',
|
||||
data: payload as unknown as OpenClawSessionHistory,
|
||||
}
|
||||
}
|
||||
if (message.event === 'message') {
|
||||
return {
|
||||
type: 'message',
|
||||
data: payload as unknown as {
|
||||
sessionKey: string
|
||||
message: OpenClawSessionHistoryMessage
|
||||
messageId?: string
|
||||
messageSeq: number
|
||||
},
|
||||
}
|
||||
}
|
||||
if (message.event === 'error') {
|
||||
const errMessage =
|
||||
typeof payload.message === 'string'
|
||||
? payload.message
|
||||
: 'OpenClaw session history stream error'
|
||||
return { type: 'error', data: { message: errMessage } }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Main orchestrator for OpenClaw integration.
|
||||
* Container lifecycle via the VM runtime, agent CRUD via in-container CLI,
|
||||
* Container lifecycle via Podman, agent CRUD via in-container CLI,
|
||||
* chat via HTTP /v1/chat/completions proxy.
|
||||
*/
|
||||
|
||||
@@ -18,11 +18,10 @@ import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
|
||||
import { getOpenClawDir } from '../../../lib/browseros-dir'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import type { MonitoringChatTurn } from '../../../monitoring/types'
|
||||
import type {
|
||||
import {
|
||||
ContainerRuntime,
|
||||
GatewayContainerSpec,
|
||||
type GatewayContainerSpec,
|
||||
} from './container-runtime'
|
||||
import { buildContainerRuntime } from './container-runtime-factory'
|
||||
import {
|
||||
OpenClawAgentAlreadyExistsError,
|
||||
OpenClawAgentNotFoundError,
|
||||
@@ -41,16 +40,14 @@ import {
|
||||
getOpenClawStateEnvPath,
|
||||
mergeEnvContent,
|
||||
} from './openclaw-env'
|
||||
import {
|
||||
OpenClawHttpClient,
|
||||
type OpenClawSessionHistory,
|
||||
type OpenClawSessionHistoryEvent,
|
||||
} from './openclaw-http-client'
|
||||
import { OpenClawHttpChatClient } from './openclaw-http-chat-client'
|
||||
import {
|
||||
type ResolvedOpenClawProviderConfig,
|
||||
resolveSupportedOpenClawProvider,
|
||||
} from './openclaw-provider-map'
|
||||
import type { OpenClawStreamEvent } from './openclaw-types'
|
||||
import { loadPodmanOverrides, savePodmanOverrides } from './podman-overrides'
|
||||
import { configurePodmanRuntime, getPodmanRuntime } from './podman-runtime'
|
||||
import { allocateGatewayPort, readPersistedGatewayPort } from './runtime-state'
|
||||
|
||||
const READY_TIMEOUT_MS = 30_000
|
||||
@@ -111,14 +108,18 @@ export interface OpenClawProviderUpdateResult {
|
||||
export interface OpenClawServiceConfig {
|
||||
browserosServerPort?: number
|
||||
resourcesDir?: string
|
||||
browserosDir?: string
|
||||
}
|
||||
|
||||
export interface OpenClawPodmanOverridesResponse {
|
||||
podmanPath: string | null
|
||||
effectivePodmanPath: string
|
||||
}
|
||||
|
||||
export class OpenClawService {
|
||||
private runtime: ContainerRuntime
|
||||
private cliClient: OpenClawCliClient
|
||||
private bootstrapCliClient: OpenClawCliClient
|
||||
private httpClient: OpenClawHttpClient
|
||||
private chatClient: OpenClawHttpChatClient
|
||||
private openclawDir: string
|
||||
private hostPort = OPENCLAW_GATEWAY_CONTAINER_PORT
|
||||
private token: string
|
||||
@@ -126,7 +127,6 @@ export class OpenClawService {
|
||||
private lastError: string | null = null
|
||||
private browserosServerPort: number
|
||||
private resourcesDir: string | null
|
||||
private browserosDir: string | undefined
|
||||
private controlPlaneStatus: OpenClawControlPlaneStatus = 'disconnected'
|
||||
private lastGatewayError: string | null = null
|
||||
private lastRecoveryReason: OpenClawGatewayRecoveryReason | null = null
|
||||
@@ -135,46 +135,25 @@ export class OpenClawService {
|
||||
|
||||
constructor(config: OpenClawServiceConfig = {}) {
|
||||
this.openclawDir = getOpenClawDir()
|
||||
this.runtime = buildContainerRuntime({
|
||||
resourcesDir: config.resourcesDir,
|
||||
projectDir: this.openclawDir,
|
||||
browserosRoot: config.browserosDir,
|
||||
})
|
||||
this.runtime = new ContainerRuntime(getPodmanRuntime(), this.openclawDir)
|
||||
this.token = crypto.randomUUID()
|
||||
this.cliClient = new OpenClawCliClient(this.runtime)
|
||||
this.bootstrapCliClient = this.buildBootstrapCliClient()
|
||||
this.httpClient = new OpenClawHttpClient(
|
||||
this.chatClient = new OpenClawHttpChatClient(
|
||||
this.hostPort,
|
||||
async () => this.token,
|
||||
)
|
||||
this.browserosServerPort =
|
||||
config.browserosServerPort ?? DEFAULT_PORTS.server
|
||||
this.resourcesDir = config.resourcesDir ?? null
|
||||
this.browserosDir = config.browserosDir
|
||||
}
|
||||
|
||||
configure(config: OpenClawServiceConfig): void {
|
||||
if (config.browserosServerPort !== undefined) {
|
||||
this.browserosServerPort = config.browserosServerPort
|
||||
}
|
||||
|
||||
let runtimeChanged = false
|
||||
if (
|
||||
config.resourcesDir !== undefined &&
|
||||
config.resourcesDir !== this.resourcesDir
|
||||
) {
|
||||
if (config.resourcesDir !== undefined) {
|
||||
this.resourcesDir = config.resourcesDir
|
||||
runtimeChanged = true
|
||||
}
|
||||
if (
|
||||
config.browserosDir !== undefined &&
|
||||
config.browserosDir !== this.browserosDir
|
||||
) {
|
||||
this.browserosDir = config.browserosDir
|
||||
runtimeChanged = true
|
||||
}
|
||||
if (runtimeChanged) {
|
||||
this.rebuildRuntimeClients()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +177,14 @@ export class OpenClawService {
|
||||
hasApiKey: !!input.apiKey,
|
||||
})
|
||||
|
||||
logProgress('Checking container runtime...')
|
||||
const available = await this.runtime.isPodmanAvailable()
|
||||
if (!available) {
|
||||
throw new Error(
|
||||
'Podman is not available. Install Podman to use OpenClaw agents.',
|
||||
)
|
||||
}
|
||||
|
||||
await this.runtime.ensureReady(logProgress)
|
||||
logProgress('Container runtime ready')
|
||||
|
||||
@@ -211,7 +198,10 @@ export class OpenClawService {
|
||||
providerKeyCount: Object.keys(provider.envValues).length,
|
||||
})
|
||||
|
||||
await this.refreshGatewayAuthToken()
|
||||
logProgress('Pulling OpenClaw image...')
|
||||
await this.runtime.pullImage(this.getGatewayImage(), logProgress)
|
||||
logProgress('Image ready')
|
||||
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
|
||||
logProgress('Bootstrapping OpenClaw config...')
|
||||
@@ -235,7 +225,8 @@ export class OpenClawService {
|
||||
logProgress('Validating OpenClaw config...')
|
||||
await this.assertConfigValid(this.bootstrapCliClient)
|
||||
|
||||
await this.refreshGatewayAuthToken()
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
|
||||
logProgress('Starting OpenClaw gateway...')
|
||||
await this.runtime.startGateway(
|
||||
@@ -294,7 +285,8 @@ export class OpenClawService {
|
||||
await this.runtime.ensureReady(logProgress)
|
||||
|
||||
logProgress('Refreshing gateway auth token...')
|
||||
await this.refreshGatewayAuthToken()
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
await this.ensureStateEnvFile()
|
||||
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
@@ -361,10 +353,10 @@ export class OpenClawService {
|
||||
})
|
||||
|
||||
this.controlPlaneStatus = 'reconnecting'
|
||||
await this.runtime.ensureReady(logProgress)
|
||||
this.stopGatewayLogTail()
|
||||
logProgress('Refreshing gateway auth token...')
|
||||
await this.refreshGatewayAuthToken()
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
await this.ensureStateEnvFile()
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
logProgress('Restarting OpenClaw gateway...')
|
||||
@@ -409,7 +401,8 @@ export class OpenClawService {
|
||||
}
|
||||
|
||||
logProgress('Reloading gateway auth token...')
|
||||
await this.refreshGatewayAuthToken()
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
this.controlPlaneStatus = 'reconnecting'
|
||||
logProgress('Reconnecting control plane...')
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
@@ -425,13 +418,28 @@ export class OpenClawService {
|
||||
} catch {
|
||||
// Best effort during shutdown
|
||||
}
|
||||
await this.runtime.stopVm()
|
||||
await this.runtime.stopMachineIfSafe()
|
||||
logger.info('OpenClaw shutdown complete')
|
||||
}
|
||||
|
||||
// ── Status ───────────────────────────────────────────────────────────
|
||||
|
||||
async getStatus(): Promise<OpenClawStatusResponse> {
|
||||
const podmanAvailable = await this.runtime.isPodmanAvailable()
|
||||
if (!podmanAvailable) {
|
||||
return {
|
||||
status: 'uninitialized',
|
||||
podmanAvailable: false,
|
||||
machineReady: false,
|
||||
port: null,
|
||||
agentCount: 0,
|
||||
error: null,
|
||||
controlPlaneStatus: 'disconnected',
|
||||
lastGatewayError: null,
|
||||
lastRecoveryReason: null,
|
||||
}
|
||||
}
|
||||
|
||||
const isSetUp = existsSync(this.getStateConfigPath())
|
||||
if (!isSetUp) {
|
||||
const machineStatus = await this.runtime.getMachineStatus()
|
||||
@@ -581,7 +589,7 @@ export class OpenClawService {
|
||||
historyLength: history.length,
|
||||
})
|
||||
return this.runControlPlaneCall(() =>
|
||||
this.httpClient.streamChat({
|
||||
this.chatClient.streamChat({
|
||||
agentId,
|
||||
sessionKey,
|
||||
message,
|
||||
@@ -590,29 +598,46 @@ export class OpenClawService {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Session History (HTTP) ───────────────────────────────────────────
|
||||
// ── Podman Overrides ─────────────────────────────────────────────────
|
||||
|
||||
async getSessionHistory(
|
||||
sessionKey: string,
|
||||
input: { limit?: number; cursor?: string; signal?: AbortSignal } = {},
|
||||
): Promise<OpenClawSessionHistory> {
|
||||
await this.assertGatewayReady()
|
||||
return this.runControlPlaneCall(() =>
|
||||
this.httpClient.getSessionHistory(sessionKey, input),
|
||||
)
|
||||
async applyPodmanOverrides(input: {
|
||||
podmanPath: string | null
|
||||
}): Promise<OpenClawPodmanOverridesResponse> {
|
||||
await savePodmanOverrides(this.openclawDir, {
|
||||
podmanPath: input.podmanPath,
|
||||
})
|
||||
|
||||
// Intentionally mutates the module-level PodmanRuntime singleton so every
|
||||
// consumer (including future service instances) sees the new path.
|
||||
configurePodmanRuntime({
|
||||
resourcesDir: this.resourcesDir ?? undefined,
|
||||
podmanPath: input.podmanPath ?? undefined,
|
||||
})
|
||||
|
||||
this.rebuildRuntimeClients()
|
||||
const effectivePodmanPath = getPodmanRuntime().getPodmanPath()
|
||||
|
||||
logger.info('Applied Podman overrides', {
|
||||
podmanPath: input.podmanPath,
|
||||
effectivePodmanPath,
|
||||
})
|
||||
|
||||
return {
|
||||
podmanPath: input.podmanPath,
|
||||
effectivePodmanPath,
|
||||
}
|
||||
}
|
||||
|
||||
async streamSessionHistory(
|
||||
sessionKey: string,
|
||||
input: { limit?: number; cursor?: string; signal?: AbortSignal } = {},
|
||||
): Promise<ReadableStream<OpenClawSessionHistoryEvent>> {
|
||||
await this.assertGatewayReady()
|
||||
return this.runControlPlaneCall(() =>
|
||||
this.httpClient.streamSessionHistory(sessionKey, input),
|
||||
)
|
||||
async getPodmanOverrides(): Promise<OpenClawPodmanOverridesResponse> {
|
||||
const { podmanPath } = await loadPodmanOverrides(this.openclawDir)
|
||||
return {
|
||||
podmanPath,
|
||||
effectivePodmanPath: getPodmanRuntime().getPodmanPath(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Provider Keys ────────────────────────────────────────────────────
|
||||
|
||||
async updateProviderKeys(input: {
|
||||
providerType: string
|
||||
providerName?: string
|
||||
@@ -656,6 +681,8 @@ export class OpenClawService {
|
||||
const isSetUp = existsSync(this.getStateConfigPath())
|
||||
if (!isSetUp) return
|
||||
|
||||
const available = await this.runtime.isPodmanAvailable()
|
||||
if (!available) return
|
||||
logger.info('Attempting OpenClaw auto-start', {
|
||||
hostPort: this.hostPort,
|
||||
})
|
||||
@@ -663,7 +690,8 @@ export class OpenClawService {
|
||||
try {
|
||||
await this.runtime.ensureReady()
|
||||
|
||||
await this.refreshGatewayAuthToken()
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
await this.ensureStateEnvFile()
|
||||
|
||||
const persistedPort = await readPersistedGatewayPort(this.openclawDir)
|
||||
@@ -671,7 +699,7 @@ export class OpenClawService {
|
||||
this.setPort(persistedPort)
|
||||
}
|
||||
|
||||
if (!(await this.isGatewayAvailable(this.hostPort))) {
|
||||
if (!(await this.runtime.isReady(this.hostPort))) {
|
||||
await this.ensureGatewayPortAllocated()
|
||||
await this.runtime.startGateway(this.buildGatewayRuntimeSpec())
|
||||
const ready = await this.runtime.waitForReady(
|
||||
@@ -709,11 +737,7 @@ export class OpenClawService {
|
||||
|
||||
private rebuildRuntimeClients(): void {
|
||||
this.stopGatewayLogTail()
|
||||
this.runtime = buildContainerRuntime({
|
||||
resourcesDir: this.resourcesDir ?? undefined,
|
||||
projectDir: this.openclawDir,
|
||||
browserosRoot: this.browserosDir,
|
||||
})
|
||||
this.runtime = new ContainerRuntime(getPodmanRuntime(), this.openclawDir)
|
||||
this.cliClient = new OpenClawCliClient(this.runtime)
|
||||
this.bootstrapCliClient = this.buildBootstrapCliClient()
|
||||
}
|
||||
@@ -721,7 +745,7 @@ export class OpenClawService {
|
||||
private setPort(hostPort: number): void {
|
||||
if (hostPort === this.hostPort) return
|
||||
this.hostPort = hostPort
|
||||
this.httpClient = new OpenClawHttpClient(
|
||||
this.chatClient = new OpenClawHttpChatClient(
|
||||
this.hostPort,
|
||||
async () => this.token,
|
||||
)
|
||||
@@ -746,34 +770,9 @@ export class OpenClawService {
|
||||
}
|
||||
|
||||
private async isGatewayAvailable(hostPort: number): Promise<boolean> {
|
||||
if (!(await this.isGatewayPortReady(hostPort))) return false
|
||||
|
||||
if (!this.tokenLoaded) {
|
||||
logger.debug(
|
||||
'OpenClaw gateway port is ready before auth token is loaded',
|
||||
{
|
||||
hostPort,
|
||||
},
|
||||
)
|
||||
return false
|
||||
if (await this.runtime.isReady(hostPort)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const client =
|
||||
hostPort === this.hostPort
|
||||
? this.httpClient
|
||||
: new OpenClawHttpClient(hostPort, async () => this.token)
|
||||
const authenticated = await client.isAuthenticated()
|
||||
if (!authenticated) {
|
||||
logger.warn('OpenClaw gateway port rejected current auth token', {
|
||||
hostPort,
|
||||
})
|
||||
}
|
||||
return authenticated
|
||||
}
|
||||
|
||||
private async isGatewayPortReady(hostPort: number): Promise<boolean> {
|
||||
if (await this.runtime.isReady(hostPort)) return true
|
||||
|
||||
const runtime = this.runtime as {
|
||||
isHealthy?: (port: number) => Promise<boolean>
|
||||
}
|
||||
@@ -1159,15 +1158,6 @@ export class OpenClawService {
|
||||
await this.loadTokenFromConfig()
|
||||
}
|
||||
|
||||
private async refreshGatewayAuthToken(): Promise<void> {
|
||||
this.tokenLoaded = false
|
||||
if (!existsSync(this.getStateConfigPath())) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.loadTokenFromConfig()
|
||||
}
|
||||
|
||||
private async loadTokenFromConfig(): Promise<void> {
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
@@ -1234,13 +1224,6 @@ export function configureOpenClawService(
|
||||
return service
|
||||
}
|
||||
|
||||
export function configureVmRuntime(config: {
|
||||
resourcesDir?: string
|
||||
browserosDir?: string
|
||||
}): OpenClawService {
|
||||
return configureOpenClawService(config)
|
||||
}
|
||||
|
||||
export function getOpenClawService(): OpenClawService {
|
||||
if (!service) service = new OpenClawService()
|
||||
return service
|
||||
|
||||
@@ -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`,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Abstraction over the Podman CLI for container lifecycle management.
|
||||
* Handles Podman machine init/start on macOS/Windows (where a Linux VM is required).
|
||||
* On Linux, machine operations are no-ops since Podman runs natively.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const isLinux = process.platform === 'linux'
|
||||
const PODMAN_BUNDLE_PATH = ['bin', 'third_party', 'podman'] as const
|
||||
const DEFAULT_MACHINE_CPUS = 4
|
||||
const DEFAULT_MACHINE_MEMORY_MB = 4096
|
||||
|
||||
export type LogFn = (msg: string) => void
|
||||
|
||||
function getPodmanBinaryName(platform: NodeJS.Platform): string {
|
||||
return platform === 'win32' ? 'podman.exe' : 'podman'
|
||||
}
|
||||
|
||||
function readPositiveInt(value: string | undefined, fallback: number): number {
|
||||
if (!value) return fallback
|
||||
const parsed = parseInt(value, 10)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
export function readMachineResources(): { cpus: number; memoryMb: number } {
|
||||
return {
|
||||
cpus: readPositiveInt(
|
||||
process.env.BROWSEROS_PODMAN_CPUS,
|
||||
DEFAULT_MACHINE_CPUS,
|
||||
),
|
||||
memoryMb: readPositiveInt(
|
||||
process.env.BROWSEROS_PODMAN_MEMORY_MB,
|
||||
DEFAULT_MACHINE_MEMORY_MB,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBundledPodmanPath(
|
||||
resourcesDir?: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string | null {
|
||||
if (!resourcesDir) return null
|
||||
|
||||
const bundledPath = join(
|
||||
resourcesDir,
|
||||
...PODMAN_BUNDLE_PATH,
|
||||
getPodmanBinaryName(platform),
|
||||
)
|
||||
|
||||
return existsSync(bundledPath) ? bundledPath : null
|
||||
}
|
||||
|
||||
export class PodmanRuntime {
|
||||
private podmanPath: string
|
||||
|
||||
constructor(config?: { podmanPath?: string }) {
|
||||
this.podmanPath = config?.podmanPath ?? 'podman'
|
||||
}
|
||||
|
||||
getPodmanPath(): string {
|
||||
return this.podmanPath
|
||||
}
|
||||
|
||||
async isPodmanAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const proc = Bun.spawn([this.podmanPath, '--version'], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
})
|
||||
return (await proc.exited) === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
if (isLinux) return { initialized: true, running: true }
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
[this.podmanPath, 'machine', 'list', '--format', 'json'],
|
||||
{ stdout: 'pipe', stderr: 'ignore' },
|
||||
)
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
|
||||
const machines = JSON.parse(output) as Array<{
|
||||
Running?: boolean
|
||||
LastUp?: string
|
||||
}>
|
||||
|
||||
if (!machines.length) return { initialized: false, running: false }
|
||||
|
||||
const machine = machines[0]
|
||||
const running =
|
||||
machine.Running === true || machine.LastUp === 'Currently running'
|
||||
|
||||
return { initialized: true, running }
|
||||
} catch {
|
||||
return { initialized: false, running: false }
|
||||
}
|
||||
}
|
||||
|
||||
async initMachine(onLog?: LogFn): Promise<void> {
|
||||
if (isLinux) return
|
||||
|
||||
const { cpus, memoryMb } = readMachineResources()
|
||||
onLog?.(`Allocating ${cpus} CPUs, ${memoryMb} MB RAM`)
|
||||
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
this.podmanPath,
|
||||
'machine',
|
||||
'init',
|
||||
'--cpus',
|
||||
String(cpus),
|
||||
'--memory',
|
||||
String(memoryMb),
|
||||
'--disk-size',
|
||||
'10',
|
||||
],
|
||||
{ stdout: 'ignore', stderr: 'pipe' },
|
||||
)
|
||||
|
||||
await this.drainStderr(proc, onLog)
|
||||
const code = await proc.exited
|
||||
if (code !== 0)
|
||||
throw new Error(`podman machine init failed with code ${code}`)
|
||||
}
|
||||
|
||||
async startMachine(onLog?: LogFn): Promise<void> {
|
||||
if (isLinux) return
|
||||
|
||||
const proc = Bun.spawn([this.podmanPath, 'machine', 'start'], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
await this.drainStderr(proc, onLog)
|
||||
const code = await proc.exited
|
||||
if (code !== 0)
|
||||
throw new Error(`podman machine start failed with code ${code}`)
|
||||
}
|
||||
|
||||
async stopMachine(): Promise<void> {
|
||||
if (isLinux) return
|
||||
|
||||
const proc = Bun.spawn([this.podmanPath, 'machine', 'stop'], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
})
|
||||
const code = await proc.exited
|
||||
if (code !== 0)
|
||||
throw new Error(`podman machine stop failed with code ${code}`)
|
||||
}
|
||||
|
||||
async ensureReady(onLog?: LogFn): Promise<void> {
|
||||
const status = await this.getMachineStatus()
|
||||
|
||||
if (!status.initialized) {
|
||||
onLog?.('Initializing Podman machine...')
|
||||
await this.initMachine(onLog)
|
||||
}
|
||||
|
||||
if (!status.running) {
|
||||
onLog?.('Starting Podman machine...')
|
||||
await this.startMachine(onLog)
|
||||
}
|
||||
}
|
||||
|
||||
async runCommand(
|
||||
args: string[],
|
||||
options?: {
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
onOutput?: (line: string) => void
|
||||
},
|
||||
): Promise<number> {
|
||||
const useStreaming = !!options?.onOutput
|
||||
const proc = Bun.spawn([this.podmanPath, ...args], {
|
||||
cwd: options?.cwd,
|
||||
env: options?.env ? { ...process.env, ...options.env } : undefined,
|
||||
stdout: useStreaming ? 'pipe' : 'ignore',
|
||||
stderr: useStreaming ? 'pipe' : 'ignore',
|
||||
})
|
||||
|
||||
if (options?.onOutput) {
|
||||
await Promise.all([
|
||||
this.drainStream(proc.stdout ?? null, options.onOutput),
|
||||
this.drainStream(proc.stderr ?? null, options.onOutput),
|
||||
])
|
||||
}
|
||||
|
||||
return proc.exited
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow container logs. Returns a stop function that terminates the
|
||||
* underlying `podman logs -f` process. Each output line is passed to
|
||||
* onLine as-is.
|
||||
*/
|
||||
tailContainerLogs(containerName: string, onLine: LogFn): () => void {
|
||||
const proc = Bun.spawn(
|
||||
[this.podmanPath, 'logs', '-f', '--tail', '0', containerName],
|
||||
{ stdout: 'pipe', stderr: 'pipe' },
|
||||
)
|
||||
|
||||
void this.drainStream(proc.stdout ?? null, onLine)
|
||||
void this.drainStream(proc.stderr ?? null, onLine)
|
||||
|
||||
let stopped = false
|
||||
return () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
try {
|
||||
proc.kill()
|
||||
} catch {
|
||||
// process may already be gone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists running container names. Used to check whether non-BrowserOS
|
||||
* containers are running before stopping the Podman machine.
|
||||
*/
|
||||
async listRunningContainers(): Promise<string[]> {
|
||||
const proc = Bun.spawn([this.podmanPath, 'ps', '--format', '{{.Names}}'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'ignore',
|
||||
})
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
|
||||
return output
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((name) => name.trim())
|
||||
}
|
||||
|
||||
private async drainStderr(
|
||||
proc: {
|
||||
stderr: ReadableStream<Uint8Array> | null
|
||||
exited: Promise<number>
|
||||
},
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
if (!onLog || !proc.stderr) return
|
||||
await this.drainStream(proc.stderr, onLog)
|
||||
}
|
||||
|
||||
private async drainStream(
|
||||
stream: ReadableStream<Uint8Array> | null,
|
||||
onLine: (line: string) => void,
|
||||
): Promise<void> {
|
||||
if (!stream) return
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed) onLine(trimmed)
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) onLine(buffer.trim())
|
||||
}
|
||||
}
|
||||
|
||||
let runtime: PodmanRuntime | null = null
|
||||
|
||||
export function configurePodmanRuntime(config: {
|
||||
resourcesDir?: string
|
||||
podmanPath?: string
|
||||
}): PodmanRuntime {
|
||||
const podmanPath =
|
||||
config.podmanPath ??
|
||||
resolveBundledPodmanPath(config.resourcesDir) ??
|
||||
'podman'
|
||||
|
||||
runtime = new PodmanRuntime({ podmanPath })
|
||||
return runtime
|
||||
}
|
||||
|
||||
export function getPodmanRuntime(): PodmanRuntime {
|
||||
if (!runtime) runtime = new PodmanRuntime()
|
||||
return runtime
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
OPENCLAW_CONTAINER_HOME,
|
||||
OPENCLAW_TERMINAL_SHELL,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { buildNerdctlCommand } from '../../../lib/container'
|
||||
import { logger } from '../../../lib/logger'
|
||||
|
||||
export const TERMINAL_HOME_DIR = OPENCLAW_CONTAINER_HOME
|
||||
@@ -12,9 +11,7 @@ const TERMINAL_NAME = 'xterm-256color'
|
||||
|
||||
interface TerminalSessionDeps {
|
||||
containerName: string
|
||||
limaHome: string
|
||||
limactlPath: string
|
||||
vmName: string
|
||||
podmanPath: string
|
||||
workingDir: string
|
||||
onExit: (exitCode: number) => void
|
||||
onOutput: (data: string) => void
|
||||
@@ -27,44 +24,32 @@ export interface TerminalSession {
|
||||
}
|
||||
|
||||
export function buildTerminalExecCommand(
|
||||
limactlPath: string,
|
||||
vmName: string,
|
||||
podmanPath: string,
|
||||
containerName: string,
|
||||
workingDir: string,
|
||||
): string[] {
|
||||
return [
|
||||
limactlPath,
|
||||
'shell',
|
||||
vmName,
|
||||
'--',
|
||||
...buildNerdctlCommand([
|
||||
'exec',
|
||||
'-it',
|
||||
'-w',
|
||||
workingDir,
|
||||
containerName,
|
||||
OPENCLAW_TERMINAL_SHELL,
|
||||
]),
|
||||
podmanPath,
|
||||
'exec',
|
||||
'-it',
|
||||
'-w',
|
||||
workingDir,
|
||||
containerName,
|
||||
OPENCLAW_TERMINAL_SHELL,
|
||||
]
|
||||
}
|
||||
|
||||
export function buildTerminalEnv(limaHome: string): NodeJS.ProcessEnv {
|
||||
return { ...process.env, LIMA_HOME: limaHome, TERM: TERMINAL_NAME }
|
||||
}
|
||||
|
||||
export function createTerminalSession(
|
||||
deps: TerminalSessionDeps,
|
||||
): TerminalSession {
|
||||
const decoder = new TextDecoder()
|
||||
const proc = Bun.spawn(
|
||||
buildTerminalExecCommand(
|
||||
deps.limactlPath,
|
||||
deps.vmName,
|
||||
deps.podmanPath,
|
||||
deps.containerName,
|
||||
deps.workingDir,
|
||||
),
|
||||
{
|
||||
cwd: '/',
|
||||
terminal: {
|
||||
cols: DEFAULT_COLS,
|
||||
rows: DEFAULT_ROWS,
|
||||
@@ -73,7 +58,7 @@ export function createTerminalSession(
|
||||
if (chunk) deps.onOutput(chunk)
|
||||
},
|
||||
},
|
||||
env: buildTerminalEnv(deps.limaHome),
|
||||
env: { ...process.env, TERM: TERMINAL_NAME },
|
||||
},
|
||||
)
|
||||
let closed = false
|
||||
|
||||
@@ -6,10 +6,12 @@ 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 {
|
||||
const dirName =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? PATHS.DEV_BROWSEROS_DIR_NAME
|
||||
? DEV_BROWSEROS_DIR_NAME
|
||||
: PATHS.BROWSEROS_DIR_NAME
|
||||
return join(homedir(), dirName)
|
||||
}
|
||||
@@ -44,37 +46,9 @@ export function getBuiltinSkillsDir(): string {
|
||||
}
|
||||
|
||||
export function getOpenClawDir(): string {
|
||||
return join(getVmStateDir(), PATHS.OPENCLAW_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getLegacyOpenClawDir(): string {
|
||||
return join(getBrowserosDir(), PATHS.OPENCLAW_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getCacheDir(): string {
|
||||
return join(getBrowserosDir(), PATHS.CACHE_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getVmCacheDir(): string {
|
||||
return join(getCacheDir(), 'vm')
|
||||
}
|
||||
|
||||
export function getLimaHomeDir(): string {
|
||||
return join(getBrowserosDir(), 'lima')
|
||||
}
|
||||
|
||||
export function getVmStateDir(): string {
|
||||
return join(getBrowserosDir(), 'vm')
|
||||
}
|
||||
|
||||
export function getVmDisksDir(): string {
|
||||
return getVmCacheDir()
|
||||
}
|
||||
|
||||
export function getAgentCacheDir(): string {
|
||||
return join(getVmCacheDir(), 'images')
|
||||
}
|
||||
|
||||
export function getLazyMonitoringDir(): string {
|
||||
return join(getBrowserosDir(), 'lazy-monitoring')
|
||||
}
|
||||
@@ -112,7 +86,6 @@ export async function ensureBrowserosDir(): Promise<void> {
|
||||
await mkdir(getBuiltinSkillsDir(), { recursive: true })
|
||||
await mkdir(getSessionsDir(), { recursive: true })
|
||||
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
|
||||
await mkdir(getAgentCacheDir(), { recursive: true })
|
||||
}
|
||||
|
||||
export async function cleanOldSessions(): Promise<void> {
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { ContainerCliError } from '../vm/errors'
|
||||
import { LimaCli } from '../vm/lima-cli'
|
||||
import type { ContainerSpec, LogFn, MountSpec, PortMapping } from './types'
|
||||
|
||||
export function buildNerdctlCommand(args: string[]): string[] {
|
||||
return ['nerdctl', ...args]
|
||||
}
|
||||
|
||||
export interface ContainerCliConfig {
|
||||
limactlPath: string
|
||||
limaHome: string
|
||||
vmName: string
|
||||
sshPath?: string
|
||||
}
|
||||
|
||||
export interface ContainerCommandResult {
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
export class ContainerCli {
|
||||
private readonly lima: LimaCli
|
||||
|
||||
constructor(private readonly cfg: ContainerCliConfig) {
|
||||
this.lima = new LimaCli({
|
||||
limactlPath: cfg.limactlPath,
|
||||
limaHome: cfg.limaHome,
|
||||
sshPath: cfg.sshPath,
|
||||
})
|
||||
}
|
||||
|
||||
async imageExists(ref: string): Promise<boolean> {
|
||||
const result = await this.runCommand(['image', 'inspect', ref])
|
||||
return result.exitCode === 0
|
||||
}
|
||||
|
||||
async pullImage(ref: string, onLog?: LogFn): Promise<void> {
|
||||
await this.runRequired(['pull', ref], onLog)
|
||||
}
|
||||
|
||||
async loadImage(tarballPath: string, onLog?: LogFn): Promise<string[]> {
|
||||
const result = await this.runRequired(['load', '-i', tarballPath], onLog)
|
||||
return parseLoadedImageRefs(result.stdout)
|
||||
}
|
||||
|
||||
async createContainer(spec: ContainerSpec, onLog?: LogFn): Promise<void> {
|
||||
await this.runRequired(buildCreateArgs(spec), onLog)
|
||||
}
|
||||
|
||||
async startContainer(name: string, onLog?: LogFn): Promise<void> {
|
||||
await this.runRequired(['start', name], onLog)
|
||||
}
|
||||
|
||||
async stopContainer(name: string, onLog?: LogFn): Promise<void> {
|
||||
const result = await this.runCommand(['stop', name], onLog)
|
||||
if (result.exitCode === 0 || isNoSuchContainer(result.stderr)) return
|
||||
throw this.commandError(['stop', name], result)
|
||||
}
|
||||
|
||||
async removeContainer(
|
||||
name: string,
|
||||
opts?: { force?: boolean },
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
const args = ['rm']
|
||||
if (opts?.force) args.push('-f')
|
||||
args.push(name)
|
||||
const result = await this.runCommand(args, onLog)
|
||||
if (result.exitCode === 0 || isNoSuchContainer(result.stderr)) return
|
||||
throw this.commandError(args, result)
|
||||
}
|
||||
|
||||
async exec(name: string, cmd: string[], onLog?: LogFn): Promise<number> {
|
||||
const result = await this.runCommand(['exec', name, ...cmd], onLog)
|
||||
return result.exitCode
|
||||
}
|
||||
|
||||
async ps(opts?: { namesOnly?: boolean }): Promise<string[]> {
|
||||
const args = opts?.namesOnly ? ['ps', '--format', '{{.Names}}'] : ['ps']
|
||||
const result = await this.runRequired(args)
|
||||
return result.stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
tailLogs(name: string, onLine: LogFn): () => void {
|
||||
const proc = this.lima.spawnShell(
|
||||
this.cfg.vmName,
|
||||
buildNerdctlCommand(['logs', '-f', '-n', '0', name]),
|
||||
{ onStdout: onLine, onStderr: onLine },
|
||||
)
|
||||
|
||||
let stopped = false
|
||||
return () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
proc.kill()
|
||||
}
|
||||
}
|
||||
|
||||
async runCommand(
|
||||
args: string[],
|
||||
onLog?: LogFn,
|
||||
): Promise<ContainerCommandResult> {
|
||||
const stdoutLines: string[] = []
|
||||
const stderrLines: string[] = []
|
||||
const exitCode = await this.lima.shell(
|
||||
this.cfg.vmName,
|
||||
buildNerdctlCommand(args),
|
||||
{
|
||||
onStdout: (line) => {
|
||||
stdoutLines.push(line)
|
||||
onLog?.(line)
|
||||
},
|
||||
onStderr: (line) => {
|
||||
stderrLines.push(line)
|
||||
onLog?.(line)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
exitCode,
|
||||
stdout: linesToOutput(stdoutLines),
|
||||
stderr: stderrLines.join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
private async runRequired(
|
||||
args: string[],
|
||||
onLog?: LogFn,
|
||||
): Promise<ContainerCommandResult> {
|
||||
const result = await this.runCommand(args, onLog)
|
||||
if (result.exitCode === 0) return result
|
||||
throw this.commandError(args, result)
|
||||
}
|
||||
|
||||
private commandError(
|
||||
args: string[],
|
||||
result: ContainerCommandResult,
|
||||
): ContainerCliError {
|
||||
return new ContainerCliError(
|
||||
`nerdctl ${args.join(' ')}`,
|
||||
result.exitCode,
|
||||
result.stderr.trim(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function buildCreateArgs(spec: ContainerSpec): string[] {
|
||||
const args = ['create', '--name', spec.name]
|
||||
|
||||
if (spec.restart) args.push('--restart', spec.restart)
|
||||
for (const port of spec.ports ?? []) args.push('-p', portArg(port))
|
||||
if (spec.envFile) args.push('--env-file', spec.envFile)
|
||||
for (const [key, value] of Object.entries(spec.env ?? {})) {
|
||||
args.push('-e', `${key}=${value}`)
|
||||
}
|
||||
for (const mount of spec.mounts ?? []) args.push('-v', mountArg(mount))
|
||||
for (const host of spec.addHosts ?? []) args.push('--add-host', host)
|
||||
if (spec.health) {
|
||||
args.push('--health-cmd', spec.health.cmd)
|
||||
if (spec.health.interval)
|
||||
args.push('--health-interval', spec.health.interval)
|
||||
if (spec.health.timeout) args.push('--health-timeout', spec.health.timeout)
|
||||
if (spec.health.retries !== undefined) {
|
||||
args.push('--health-retries', String(spec.health.retries))
|
||||
}
|
||||
}
|
||||
|
||||
args.push(spec.image)
|
||||
args.push(...(spec.command ?? []))
|
||||
return args
|
||||
}
|
||||
|
||||
function portArg(port: PortMapping): string {
|
||||
const host = port.hostIp ? `${port.hostIp}:${port.hostPort}` : port.hostPort
|
||||
return `${host}:${port.containerPort}`
|
||||
}
|
||||
|
||||
function mountArg(mount: MountSpec): string {
|
||||
return `${mount.source}:${mount.target}${mount.readonly ? ':ro' : ''}`
|
||||
}
|
||||
|
||||
function parseLoadedImageRefs(stdout: string): string[] {
|
||||
return stdout
|
||||
.split('\n')
|
||||
.map((line) => line.match(/^Loaded image(?:\(s\))?:\s*(.+)$/i)?.[1]?.trim())
|
||||
.filter((ref): ref is string => !!ref)
|
||||
}
|
||||
|
||||
function isNoSuchContainer(stderr: string): boolean {
|
||||
const lower = stderr.toLowerCase()
|
||||
return lower.includes('no such container') || lower.includes('not found')
|
||||
}
|
||||
|
||||
function linesToOutput(lines: string[]): string {
|
||||
if (lines.length === 0) return ''
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { basename, join } from 'node:path'
|
||||
import { ContainerCliError, ImageLoadError } from '../vm/errors'
|
||||
import type { VmManifest } from '../vm/manifest'
|
||||
import type { Arch } from '../vm/paths'
|
||||
import { getImageCacheDir, hostPathToGuest } from '../vm/paths'
|
||||
import type { ContainerCli } from './container-cli'
|
||||
import type { LogFn } from './types'
|
||||
|
||||
export class ImageLoader {
|
||||
constructor(
|
||||
private readonly cli: ContainerCli,
|
||||
private readonly manifest: VmManifest,
|
||||
private readonly arch: Arch,
|
||||
private readonly browserosRoot?: string,
|
||||
) {}
|
||||
|
||||
async ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void> {
|
||||
if (await this.cli.imageExists(ref)) return
|
||||
|
||||
const tarball = this.resolveTarball(ref)
|
||||
const hostPath = join(
|
||||
getImageCacheDir(this.browserosRoot),
|
||||
basename(tarball.key),
|
||||
)
|
||||
const guestPath = hostPathToGuest(hostPath, this.browserosRoot)
|
||||
|
||||
try {
|
||||
await this.cli.loadImage(guestPath, onLog)
|
||||
} catch (error) {
|
||||
if (error instanceof ContainerCliError) {
|
||||
throw new ImageLoadError(ref, `load failed: ${error.stderr}`, error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!(await this.cli.imageExists(ref))) {
|
||||
throw new ImageLoadError(
|
||||
ref,
|
||||
`image not present after successful load of ${guestPath}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private resolveTarball(
|
||||
ref: string,
|
||||
): VmManifest['agents'][string]['tarballs'][Arch] {
|
||||
for (const agent of Object.values(this.manifest.agents)) {
|
||||
if (`${agent.image}:${agent.version}` !== ref) continue
|
||||
const tarball = agent.tarballs[this.arch]
|
||||
if (!tarball) {
|
||||
throw new ImageLoadError(ref, `no ${this.arch} tarball in manifest`)
|
||||
}
|
||||
return tarball
|
||||
}
|
||||
|
||||
throw new ImageLoadError(ref, `no agent in manifest matches ${ref}`)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export * from './container-cli'
|
||||
export * from './image-loader'
|
||||
export * from './types'
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export type LogFn = (msg: string) => void
|
||||
|
||||
export interface PortMapping {
|
||||
hostIp?: string
|
||||
hostPort: number
|
||||
containerPort: number
|
||||
}
|
||||
|
||||
export interface MountSpec {
|
||||
source: string
|
||||
target: string
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export interface HealthConfig {
|
||||
cmd: string
|
||||
interval?: string
|
||||
timeout?: string
|
||||
retries?: number
|
||||
}
|
||||
|
||||
export interface ContainerSpec {
|
||||
name: string
|
||||
image: string
|
||||
restart?: 'no' | 'unless-stopped' | 'always'
|
||||
ports?: PortMapping[]
|
||||
env?: Record<string, string>
|
||||
envFile?: string
|
||||
mounts?: MountSpec[]
|
||||
addHosts?: string[]
|
||||
health?: HealthConfig
|
||||
command?: string[]
|
||||
}
|
||||
|
||||
export interface LogLine {
|
||||
stream: 'stdout' | 'stderr'
|
||||
line: string
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export class VmError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = new.target.name
|
||||
}
|
||||
}
|
||||
|
||||
export class VmNotReadyError extends VmError {}
|
||||
|
||||
export class VmStateCorruptedError extends VmError {}
|
||||
|
||||
export class LimaCommandError extends VmError {
|
||||
constructor(
|
||||
command: string,
|
||||
public readonly exitCode: number,
|
||||
public readonly stderr: string,
|
||||
) {
|
||||
super(`${command} failed with exit code ${exitCode}: ${stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
export class ContainerCliError extends VmError {
|
||||
constructor(
|
||||
command: string,
|
||||
public readonly exitCode: number,
|
||||
public readonly stderr: string,
|
||||
) {
|
||||
super(`${command} failed with exit code ${exitCode}: ${stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageLoadError extends VmError {
|
||||
constructor(
|
||||
public readonly imageRef: string,
|
||||
message: string,
|
||||
public override readonly cause?: unknown,
|
||||
) {
|
||||
super(`failed to load image ${imageRef}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export class ManifestMissingError extends VmError {
|
||||
constructor(public readonly manifestPath: string) {
|
||||
super(manifestMissingMessage(manifestPath))
|
||||
}
|
||||
}
|
||||
|
||||
function manifestMissingMessage(manifestPath: string): string {
|
||||
const message = `VM manifest is missing at ${manifestPath}`
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `${message}; run bun run dev:setup before starting the server`
|
||||
}
|
||||
return message
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export * from './errors'
|
||||
export * from './lima-cli'
|
||||
export * from './lima-config'
|
||||
export * from './manifest'
|
||||
export * from './paths'
|
||||
export * from './telemetry'
|
||||
export * from './vm-runtime'
|
||||
@@ -1,270 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { logger } from '../logger'
|
||||
import { LimaCommandError, VmNotReadyError } from './errors'
|
||||
import { getLimaSshConfigPath } from './paths'
|
||||
import { VM_TELEMETRY_EVENTS } from './telemetry'
|
||||
|
||||
export interface LimaListEntry {
|
||||
name: string
|
||||
status: string
|
||||
dir: string
|
||||
}
|
||||
|
||||
export interface LimaCliConfig {
|
||||
limactlPath: string
|
||||
limaHome: string
|
||||
sshPath?: string
|
||||
}
|
||||
|
||||
export interface LimaShellStreams {
|
||||
onStdout?: (line: string) => void
|
||||
onStderr?: (line: string) => void
|
||||
}
|
||||
|
||||
export interface LimaShellProcess {
|
||||
kill: () => void
|
||||
exited: Promise<number>
|
||||
}
|
||||
|
||||
const LIMA_VERBOSE_LOGGING = false
|
||||
|
||||
export class LimaCli {
|
||||
constructor(private readonly cfg: LimaCliConfig) {}
|
||||
|
||||
async list(): Promise<LimaListEntry[]> {
|
||||
const result = await this.run(['list', '--format', 'json'])
|
||||
if (!result.stdout.trim()) {
|
||||
logger.debug('Lima list returned no instances', {
|
||||
limaHome: this.cfg.limaHome,
|
||||
})
|
||||
return []
|
||||
}
|
||||
const entries = parseLimaList(result.stdout)
|
||||
logger.debug('Lima list parsed', {
|
||||
limaHome: this.cfg.limaHome,
|
||||
count: entries.length,
|
||||
entries: entries.map((e) => ({ name: e.name, status: e.status })),
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
async create(name: string, yamlPath: string): Promise<void> {
|
||||
await this.runChecked('create', [
|
||||
'create',
|
||||
'--tty=false',
|
||||
`--name=${name}`,
|
||||
yamlPath,
|
||||
])
|
||||
}
|
||||
|
||||
async start(name: string): Promise<void> {
|
||||
logger.info('Invoking limactl start', {
|
||||
vmName: name,
|
||||
limaHome: this.cfg.limaHome,
|
||||
note: 'this command blocks until boot reaches READY; may take 40-120s on first boot',
|
||||
})
|
||||
await this.runChecked('start', ['start', '--tty=false', name])
|
||||
}
|
||||
|
||||
async stop(name: string): Promise<void> {
|
||||
await this.runChecked('stop', ['stop', name])
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<void> {
|
||||
await this.runChecked('delete', ['delete', '--force', name])
|
||||
}
|
||||
|
||||
async shell(
|
||||
name: string,
|
||||
args: string[],
|
||||
streams?: LimaShellStreams,
|
||||
): Promise<number> {
|
||||
const proc = this.spawnShell(name, args, streams)
|
||||
return proc.exited
|
||||
}
|
||||
|
||||
spawnShell(
|
||||
name: string,
|
||||
args: string[],
|
||||
streams?: LimaShellStreams,
|
||||
): LimaShellProcess {
|
||||
const configPath = getLimaSshConfigPath(this.cfg.limaHome, name)
|
||||
if (!existsSync(configPath)) {
|
||||
throw new VmNotReadyError(
|
||||
`lima ssh.config not found at ${configPath}; VM has not been started`,
|
||||
)
|
||||
}
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
this.cfg.sshPath ?? 'ssh',
|
||||
'-F',
|
||||
configPath,
|
||||
`lima-${name}`,
|
||||
shellQuoteCommand(args),
|
||||
],
|
||||
{
|
||||
cwd: '/',
|
||||
env: this.env(),
|
||||
stdout: streams?.onStdout ? 'pipe' : 'ignore',
|
||||
stderr: streams?.onStderr ? 'pipe' : 'ignore',
|
||||
},
|
||||
)
|
||||
|
||||
const drained = Promise.all([
|
||||
drainStream(proc.stdout ?? null, streams?.onStdout),
|
||||
drainStream(proc.stderr ?? null, streams?.onStderr),
|
||||
])
|
||||
const exited = drained.then(() => proc.exited)
|
||||
return {
|
||||
exited,
|
||||
kill: () => {
|
||||
try {
|
||||
proc.kill()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private async runChecked(command: string, args: string[]): Promise<void> {
|
||||
const result = await this.run(args)
|
||||
if (result.exitCode !== 0) {
|
||||
throw new LimaCommandError(
|
||||
`limactl ${command}`,
|
||||
result.exitCode,
|
||||
result.stderr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async run(args: string[]): Promise<{
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}> {
|
||||
const started = Date.now()
|
||||
const proc = Bun.spawn([this.cfg.limactlPath, ...args], {
|
||||
env: this.env(),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
logger.debug(VM_TELEMETRY_EVENTS.limaSpawn, {
|
||||
pid: proc.pid,
|
||||
args,
|
||||
limaHome: this.cfg.limaHome,
|
||||
})
|
||||
|
||||
const stderrLogger = LIMA_VERBOSE_LOGGING
|
||||
? (line: string) => {
|
||||
logger.debug(VM_TELEMETRY_EVENTS.limaStderrChunk, {
|
||||
pid: proc.pid,
|
||||
firstArg: args[0],
|
||||
line,
|
||||
})
|
||||
}
|
||||
: undefined
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
drainToString(proc.stdout),
|
||||
drainToString(proc.stderr, stderrLogger),
|
||||
proc.exited,
|
||||
])
|
||||
const durationMs = Date.now() - started
|
||||
logger.debug(VM_TELEMETRY_EVENTS.limaExit, {
|
||||
pid: proc.pid,
|
||||
firstArg: args[0],
|
||||
exitCode,
|
||||
durationMs,
|
||||
stdoutLen: stdout.length,
|
||||
stderrLen: stderr.length,
|
||||
})
|
||||
return { exitCode, stdout, stderr }
|
||||
}
|
||||
|
||||
private env(): NodeJS.ProcessEnv {
|
||||
return { ...process.env, LIMA_HOME: this.cfg.limaHome }
|
||||
}
|
||||
}
|
||||
|
||||
async function drainToString(
|
||||
stream: ReadableStream<Uint8Array> | null,
|
||||
onLine?: (line: string) => void,
|
||||
): Promise<string> {
|
||||
if (!stream) return ''
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let output = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
output += chunk
|
||||
buffer += chunk
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed && onLine) onLine(trimmed)
|
||||
}
|
||||
}
|
||||
if (buffer.trim() && onLine) onLine(buffer.trim())
|
||||
return output
|
||||
}
|
||||
|
||||
function parseLimaList(output: string): LimaListEntry[] {
|
||||
const trimmed = output.trim()
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown
|
||||
if (Array.isArray(parsed)) return parsed.map(toLimaListEntry)
|
||||
return [toLimaListEntry(parsed)]
|
||||
} catch {
|
||||
return trimmed.split('\n').map((line) => toLimaListEntry(JSON.parse(line)))
|
||||
}
|
||||
}
|
||||
|
||||
function toLimaListEntry(input: unknown): LimaListEntry {
|
||||
const entry = input as Partial<LimaListEntry>
|
||||
return {
|
||||
name: entry.name ?? '',
|
||||
status: entry.status ?? '',
|
||||
dir: entry.dir ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuoteCommand(args: string[]): string {
|
||||
return args.map(shellQuote).join(' ')
|
||||
}
|
||||
|
||||
function shellQuote(arg: string): string {
|
||||
return `'${arg.replaceAll("'", "'\\''")}'`
|
||||
}
|
||||
|
||||
async function drainStream(
|
||||
stream: ReadableStream<Uint8Array> | null,
|
||||
onLine?: (line: string) => void,
|
||||
): Promise<void> {
|
||||
if (!stream || !onLine) return
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
if (line.trim()) onLine(line.trim())
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) onLine(buffer.trim())
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export function renderLimaTemplate(
|
||||
template: string,
|
||||
cfg: {
|
||||
vmStateDir: string
|
||||
imageCacheDir: string
|
||||
},
|
||||
): string {
|
||||
const mounts = [
|
||||
'mounts:',
|
||||
`- location: "${cfg.vmStateDir}"`,
|
||||
' mountPoint: "/mnt/browseros/vm"',
|
||||
' writable: true',
|
||||
`- location: "${cfg.imageCacheDir}"`,
|
||||
' mountPoint: "/mnt/browseros/cache/images"',
|
||||
' writable: false',
|
||||
].join('\n')
|
||||
|
||||
if (!template.includes('mounts: []')) {
|
||||
throw new Error('BrowserOS VM Lima template is missing mounts: [] marker')
|
||||
}
|
||||
|
||||
return template.replace('mounts: []', mounts)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { ManifestMissingError } from './errors'
|
||||
import type { Arch } from './paths'
|
||||
import { getCachedManifestPath, getInstalledManifestPath } from './paths'
|
||||
|
||||
export interface VmArtifact {
|
||||
key: string
|
||||
sha256: string
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export interface VmAgentEntry {
|
||||
image: string
|
||||
version: string
|
||||
tarballs: Record<Arch, VmArtifact>
|
||||
}
|
||||
|
||||
export interface VmManifest {
|
||||
schemaVersion: number
|
||||
updatedAt: string
|
||||
agents: Record<string, VmAgentEntry>
|
||||
}
|
||||
|
||||
export type VersionComparison = 'same' | 'upgrade' | 'downgrade' | 'fresh'
|
||||
|
||||
export async function readCachedManifest(
|
||||
browserosRoot?: string,
|
||||
): Promise<VmManifest> {
|
||||
const manifestPath = getCachedManifestPath(browserosRoot)
|
||||
if (!existsSync(manifestPath)) throw new ManifestMissingError(manifestPath)
|
||||
return readManifest(manifestPath)
|
||||
}
|
||||
|
||||
export async function readInstalledManifest(
|
||||
browserosRoot?: string,
|
||||
): Promise<VmManifest | null> {
|
||||
const manifestPath = getInstalledManifestPath(browserosRoot)
|
||||
if (!existsSync(manifestPath)) return null
|
||||
return readManifest(manifestPath)
|
||||
}
|
||||
|
||||
export async function writeInstalledManifest(
|
||||
manifest: VmManifest,
|
||||
browserosRoot?: string,
|
||||
): Promise<void> {
|
||||
const manifestPath = getInstalledManifestPath(browserosRoot)
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
const tempPath = `${manifestPath}.${process.pid}.${Date.now()}.tmp`
|
||||
await writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
await rename(tempPath, manifestPath)
|
||||
}
|
||||
|
||||
export function compareVersions(
|
||||
installed: VmManifest | null,
|
||||
cached: VmManifest,
|
||||
): VersionComparison {
|
||||
if (!installed) return 'fresh'
|
||||
const comparison = compareVersionStrings(
|
||||
installed.updatedAt,
|
||||
cached.updatedAt,
|
||||
)
|
||||
if (comparison === 0) return 'same'
|
||||
return comparison < 0 ? 'upgrade' : 'downgrade'
|
||||
}
|
||||
|
||||
export function agentForArch(
|
||||
manifest: VmManifest,
|
||||
name: string,
|
||||
arch: Arch,
|
||||
): {
|
||||
image: string
|
||||
version: string
|
||||
tarball: VmManifest['agents'][string]['tarballs'][Arch]
|
||||
} {
|
||||
const agent = manifest.agents[name]
|
||||
if (!agent) throw new Error(`missing agent in VM manifest: ${name}`)
|
||||
const tarball = agent.tarballs[arch]
|
||||
if (!tarball) throw new Error(`missing ${arch} tarball for agent ${name}`)
|
||||
return {
|
||||
image: agent.image,
|
||||
version: agent.version,
|
||||
tarball,
|
||||
}
|
||||
}
|
||||
|
||||
async function readManifest(path: string): Promise<VmManifest> {
|
||||
return JSON.parse(await readFile(path, 'utf8')) as VmManifest
|
||||
}
|
||||
|
||||
function compareVersionStrings(left: string, right: string): number {
|
||||
if (left < right) return -1
|
||||
if (left > right) return 1
|
||||
return 0
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { accessSync, constants, existsSync } from 'node:fs'
|
||||
import { homedir, arch as osArch } from 'node:os'
|
||||
import {
|
||||
delimiter,
|
||||
dirname,
|
||||
isAbsolute,
|
||||
join,
|
||||
relative,
|
||||
resolve,
|
||||
sep,
|
||||
} from 'node:path'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
|
||||
export const VM_NAME = 'browseros-vm'
|
||||
export const GUEST_VM_STATE = '/mnt/browseros/vm'
|
||||
export const GUEST_IMAGE_CACHE = '/mnt/browseros/cache/images'
|
||||
const HOST_LIMACTL_BINARY = 'limactl'
|
||||
|
||||
export type Arch = 'arm64' | 'x64'
|
||||
|
||||
function rootDir(): string {
|
||||
const base =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? PATHS.DEV_BROWSEROS_DIR_NAME
|
||||
: PATHS.BROWSEROS_DIR_NAME
|
||||
return join(homedir(), base)
|
||||
}
|
||||
|
||||
export function detectArch(arch: NodeJS.Architecture = osArch()): Arch {
|
||||
if (arch === 'arm64') return 'arm64'
|
||||
if (arch === 'x64') return 'x64'
|
||||
throw new Error(`unsupported host arch: ${arch}`)
|
||||
}
|
||||
|
||||
export function getLimaHomeDir(browserosRoot = rootDir()): string {
|
||||
return join(browserosRoot, 'lima')
|
||||
}
|
||||
|
||||
export function getVmStateDir(browserosRoot = rootDir()): string {
|
||||
return join(browserosRoot, 'vm')
|
||||
}
|
||||
|
||||
export function getVmCacheDir(browserosRoot = rootDir()): string {
|
||||
return join(browserosRoot, PATHS.CACHE_DIR_NAME, 'vm')
|
||||
}
|
||||
|
||||
export function getImageCacheDir(browserosRoot = rootDir()): string {
|
||||
return join(getVmCacheDir(browserosRoot), 'images')
|
||||
}
|
||||
|
||||
export function getCachedManifestPath(browserosRoot = rootDir()): string {
|
||||
return join(getVmCacheDir(browserosRoot), 'manifest.json')
|
||||
}
|
||||
|
||||
export function getInstalledManifestPath(browserosRoot = rootDir()): string {
|
||||
return join(getVmStateDir(browserosRoot), 'manifest.json')
|
||||
}
|
||||
|
||||
export function getContainerdSocketPath(browserosRoot = rootDir()): string {
|
||||
return join(getLimaHomeDir(browserosRoot), VM_NAME, 'sock', 'containerd.sock')
|
||||
}
|
||||
|
||||
export function getLimaSocketPath(browserosRoot = rootDir()): string {
|
||||
return getContainerdSocketPath(browserosRoot)
|
||||
}
|
||||
|
||||
export function getLimaSshConfigPath(limaHome: string, name: string): string {
|
||||
return join(limaHome, name, 'ssh.config')
|
||||
}
|
||||
|
||||
export function compressedDiskPath(
|
||||
version: string,
|
||||
arch: Arch,
|
||||
browserosRoot = rootDir(),
|
||||
): string {
|
||||
return join(
|
||||
getVmCacheDir(browserosRoot),
|
||||
`browseros-vm-${version}-${arch}.qcow2.zst`,
|
||||
)
|
||||
}
|
||||
|
||||
export function decompressedDiskPath(
|
||||
version: string,
|
||||
arch: Arch,
|
||||
browserosRoot = rootDir(),
|
||||
): string {
|
||||
return join(
|
||||
getVmCacheDir(browserosRoot),
|
||||
`browseros-vm-${version}-${arch}.qcow2`,
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveBundledLimactl(resourcesDir: string): string {
|
||||
if (usesHostVmTools()) return resolveHostLimactl()
|
||||
|
||||
const candidate = join(resourcesDir, 'bin', 'third_party', 'lima', 'limactl')
|
||||
if (!existsSync(candidate)) {
|
||||
throw new Error(
|
||||
`bundled limactl not found at ${candidate}; see the build-tools README and run bun run cache:sync`,
|
||||
)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
function resolveHostLimactl(): string {
|
||||
const resolved = findExecutableOnPath(HOST_LIMACTL_BINARY)
|
||||
if (resolved) return resolved
|
||||
throw new Error(
|
||||
'Lima is not installed or limactl is not on PATH. Install with brew install lima.',
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveBundledLimaTemplate(resourcesDir: string): string {
|
||||
if (usesHostVmTools()) {
|
||||
const sourceTemplate = findSourceLimaTemplate(resourcesDir)
|
||||
if (sourceTemplate) return sourceTemplate
|
||||
}
|
||||
|
||||
const candidate = join(resourcesDir, 'vm', 'browseros-vm.yaml')
|
||||
if (!existsSync(candidate)) {
|
||||
throw new Error(
|
||||
`bundled Lima template not found at ${candidate}; see the build-tools README and run bun run cache:sync`,
|
||||
)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
function usesHostVmTools(): boolean {
|
||||
return (
|
||||
process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'
|
||||
)
|
||||
}
|
||||
|
||||
function findExecutableOnPath(binary: string): string | null {
|
||||
const pathEnv = process.env.PATH
|
||||
if (!pathEnv) return null
|
||||
for (const dir of pathEnv.split(delimiter)) {
|
||||
if (!dir) continue
|
||||
const candidate = join(dir, binary)
|
||||
try {
|
||||
accessSync(candidate, constants.X_OK)
|
||||
return candidate
|
||||
} catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findSourceLimaTemplate(resourcesDir: string): string | null {
|
||||
let current = resolve(resourcesDir)
|
||||
while (true) {
|
||||
const rootCandidate = join(
|
||||
current,
|
||||
'packages',
|
||||
'build-tools',
|
||||
'template',
|
||||
'browseros-vm.yaml',
|
||||
)
|
||||
if (existsSync(rootCandidate)) return rootCandidate
|
||||
|
||||
const packageCandidate = join(
|
||||
current,
|
||||
'build-tools',
|
||||
'template',
|
||||
'browseros-vm.yaml',
|
||||
)
|
||||
if (existsSync(packageCandidate)) return packageCandidate
|
||||
|
||||
const parent = dirname(current)
|
||||
if (parent === current) return null
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
export function hostPathToGuest(
|
||||
hostPath: string,
|
||||
browserosRoot = rootDir(),
|
||||
): string {
|
||||
const vmState = getVmStateDir(browserosRoot)
|
||||
const imageCache = getImageCacheDir(browserosRoot)
|
||||
const vmStateRelative = mountedRelativePath(vmState, hostPath)
|
||||
if (vmStateRelative !== null)
|
||||
return guestPath(GUEST_VM_STATE, vmStateRelative)
|
||||
|
||||
const imageCacheRelative = mountedRelativePath(imageCache, hostPath)
|
||||
if (imageCacheRelative !== null) {
|
||||
return guestPath(GUEST_IMAGE_CACHE, imageCacheRelative)
|
||||
}
|
||||
|
||||
throw new Error(`host path ${hostPath} is not under any known guest mount`)
|
||||
}
|
||||
|
||||
function mountedRelativePath(parent: string, child: string): string | null {
|
||||
const path = relative(parent, child)
|
||||
if (path === '') return ''
|
||||
if (path.startsWith('..') || isAbsolute(path)) return null
|
||||
return path
|
||||
}
|
||||
|
||||
function guestPath(root: string, relativePath: string): string {
|
||||
if (!relativePath) return root
|
||||
return `${root}/${relativePath.split(sep).join('/')}`
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export const VM_TELEMETRY_EVENTS = {
|
||||
ensureReadyStart: 'vm.ensure_ready.start',
|
||||
ensureReadyOk: 'vm.ensure_ready.ok',
|
||||
ensureReadyBranch: 'vm.ensure_ready.branch',
|
||||
create: 'vm.create',
|
||||
start: 'vm.start',
|
||||
stop: 'vm.stop',
|
||||
upgradeDetected: 'vm.upgrade.detected',
|
||||
downgradeDetected: 'vm.downgrade.detected',
|
||||
upgradeSwap: 'vm.upgrade.swap',
|
||||
upgradeReplay: 'vm.upgrade.replay',
|
||||
resetDetected: 'vm.reset.detected',
|
||||
resetOk: 'vm.reset.ok',
|
||||
nerdctlWaitStart: 'vm.nerdctl_wait.start',
|
||||
nerdctlWaitOk: 'vm.nerdctl_wait.ok',
|
||||
nerdctlWaitPoll: 'vm.nerdctl_wait.poll',
|
||||
nerdctlWaitTimeout: 'vm.nerdctl_wait.timeout',
|
||||
manifestMissing: 'vm.manifest.missing',
|
||||
manifestCompared: 'vm.manifest.compared',
|
||||
manifestWritten: 'vm.manifest.written',
|
||||
migrationOpenClawMoved: 'vm.migration.openclaw_moved',
|
||||
limaSpawn: 'vm.lima.spawn',
|
||||
limaExit: 'vm.lima.exit',
|
||||
limaStderrChunk: 'vm.lima.stderr_chunk',
|
||||
provisionYamlWrite: 'vm.provision.yaml_write',
|
||||
provisionCreateStart: 'vm.provision.create.start',
|
||||
provisionCreateOk: 'vm.provision.create.ok',
|
||||
provisionStartBegin: 'vm.provision.start.begin',
|
||||
provisionStartOk: 'vm.provision.start.ok',
|
||||
} as const
|
||||
@@ -1,325 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { logger } from '../logger'
|
||||
import { LimaCommandError, VmError, VmNotReadyError } from './errors'
|
||||
import { LimaCli } from './lima-cli'
|
||||
import { renderLimaTemplate } from './lima-config'
|
||||
import {
|
||||
compareVersions,
|
||||
readCachedManifest,
|
||||
readInstalledManifest,
|
||||
writeInstalledManifest,
|
||||
} from './manifest'
|
||||
import { getImageCacheDir, getVmStateDir, VM_NAME } from './paths'
|
||||
import { VM_TELEMETRY_EVENTS } from './telemetry'
|
||||
|
||||
export type LogFn = (msg: string) => void
|
||||
const ROOTLESS_CONTAINERD_MARKER = 'runtime:containerd-rootless'
|
||||
|
||||
export interface VmRuntimeDeps {
|
||||
limactlPath: string
|
||||
limaHome: string
|
||||
sshPath?: string
|
||||
templatePath?: string
|
||||
browserosRoot?: string
|
||||
readinessTimeoutMs?: number
|
||||
readinessPollMs?: number
|
||||
}
|
||||
|
||||
export class VmRuntime {
|
||||
private readonly cli: LimaCli
|
||||
private readonly readinessTimeoutMs: number
|
||||
private readonly readinessPollMs: number
|
||||
private defaultGateway: string | null = null
|
||||
|
||||
constructor(private readonly deps: VmRuntimeDeps) {
|
||||
this.cli = new LimaCli({
|
||||
limactlPath: deps.limactlPath,
|
||||
limaHome: deps.limaHome,
|
||||
sshPath: deps.sshPath,
|
||||
})
|
||||
this.readinessTimeoutMs = deps.readinessTimeoutMs ?? 60_000
|
||||
this.readinessPollMs = deps.readinessPollMs ?? 500
|
||||
}
|
||||
|
||||
async ensureReady(onLog?: LogFn): Promise<void> {
|
||||
const started = Date.now()
|
||||
logger.info(VM_TELEMETRY_EVENTS.ensureReadyStart, {
|
||||
limaHome: this.deps.limaHome,
|
||||
browserosRoot: this.deps.browserosRoot,
|
||||
templatePath: this.deps.templatePath,
|
||||
limactlPath: this.deps.limactlPath,
|
||||
})
|
||||
|
||||
const cached = await readCachedManifest(this.deps.browserosRoot)
|
||||
const installed = await readInstalledManifest(this.deps.browserosRoot)
|
||||
const versionComparison = compareVersions(installed, cached)
|
||||
logger.debug(VM_TELEMETRY_EVENTS.manifestCompared, {
|
||||
versionComparison,
|
||||
installedUpdatedAt: installed?.updatedAt ?? null,
|
||||
cachedUpdatedAt: cached.updatedAt,
|
||||
})
|
||||
|
||||
const vms = await this.cli.list()
|
||||
const existing = vms.find((vm) => vm.name === VM_NAME)
|
||||
let shouldWriteInstalledManifest =
|
||||
!existing || versionComparison === 'fresh' || versionComparison === 'same'
|
||||
|
||||
let branch = !existing
|
||||
? 'provision-fresh'
|
||||
: existing.status !== 'Running'
|
||||
? 'start-existing'
|
||||
: versionComparison === 'upgrade'
|
||||
? 'running-upgrade-warn'
|
||||
: versionComparison === 'downgrade'
|
||||
? 'running-downgrade-warn'
|
||||
: 'running-same'
|
||||
logger.info(VM_TELEMETRY_EVENTS.ensureReadyBranch, {
|
||||
branch,
|
||||
existingStatus: existing?.status ?? null,
|
||||
versionComparison,
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
await this.provisionFresh(onLog)
|
||||
} else {
|
||||
if (existing.status !== 'Running') {
|
||||
onLog?.('Starting BrowserOS VM...')
|
||||
await this.cli.start(VM_NAME)
|
||||
}
|
||||
if (
|
||||
!(await this.isReady()) &&
|
||||
(await this.needsContainerdReprovision())
|
||||
) {
|
||||
branch = 'recreate-legacy-runtime'
|
||||
shouldWriteInstalledManifest = true
|
||||
await this.recreateForContainerd(onLog)
|
||||
} else if (versionComparison === 'upgrade') {
|
||||
logger.warn(VM_TELEMETRY_EVENTS.upgradeDetected, {
|
||||
from: installed?.updatedAt ?? null,
|
||||
to: cached.updatedAt,
|
||||
})
|
||||
} else if (versionComparison === 'downgrade') {
|
||||
logger.warn(VM_TELEMETRY_EVENTS.downgradeDetected, {
|
||||
from: installed?.updatedAt ?? null,
|
||||
to: cached.updatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await this.waitForRootlessNerdctl(this.readinessTimeoutMs)
|
||||
if (shouldWriteInstalledManifest) {
|
||||
await writeInstalledManifest(cached, this.deps.browserosRoot)
|
||||
logger.debug(VM_TELEMETRY_EVENTS.manifestWritten, {
|
||||
updatedAt: cached.updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(VM_TELEMETRY_EVENTS.ensureReadyOk, {
|
||||
durationMs: Date.now() - started,
|
||||
branch,
|
||||
})
|
||||
}
|
||||
|
||||
async stopVm(): Promise<void> {
|
||||
try {
|
||||
await this.cli.stop(VM_NAME)
|
||||
} catch (error) {
|
||||
if (error instanceof LimaCommandError && isAlreadyStopped(error.stderr)) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async runCommand(
|
||||
args: string[],
|
||||
opts?: { onOutput?: LogFn },
|
||||
): Promise<number> {
|
||||
return this.cli.shell(VM_NAME, args, {
|
||||
onStdout: opts?.onOutput,
|
||||
onStderr: opts?.onOutput,
|
||||
})
|
||||
}
|
||||
|
||||
async reset(_reason: string): Promise<never> {
|
||||
throw notImplemented('VmRuntime.reset')
|
||||
}
|
||||
|
||||
async performUpgrade(): Promise<never> {
|
||||
throw notImplemented('VmRuntime.performUpgrade')
|
||||
}
|
||||
|
||||
async getDefaultGateway(): Promise<string> {
|
||||
if (this.defaultGateway) return this.defaultGateway
|
||||
|
||||
const lines: string[] = []
|
||||
const exitCode = await this.runCommand(
|
||||
['ip', '-4', 'route', 'show', 'default'],
|
||||
{
|
||||
onOutput: (line) => lines.push(line),
|
||||
},
|
||||
)
|
||||
if (exitCode !== 0) {
|
||||
throw new VmNotReadyError(
|
||||
`failed to resolve VM default gateway; ip route exited ${exitCode}`,
|
||||
)
|
||||
}
|
||||
|
||||
const gateway = parseDefaultGateway(lines.join('\n'))
|
||||
if (!gateway) {
|
||||
throw new VmNotReadyError('failed to resolve VM default gateway')
|
||||
}
|
||||
this.defaultGateway = gateway
|
||||
return gateway
|
||||
}
|
||||
|
||||
async isReady(): Promise<boolean> {
|
||||
return this.isRootlessNerdctlReady()
|
||||
}
|
||||
|
||||
getLimactlPath(): string {
|
||||
return this.deps.limactlPath
|
||||
}
|
||||
|
||||
private async provisionFresh(onLog?: LogFn): Promise<void> {
|
||||
this.defaultGateway = null
|
||||
const yaml = await this.buildLimaYaml()
|
||||
const yamlPath = join(this.deps.limaHome, `${VM_NAME}.yaml`)
|
||||
await mkdir(dirname(yamlPath), { recursive: true })
|
||||
await writeFile(yamlPath, yaml)
|
||||
logger.info(VM_TELEMETRY_EVENTS.provisionYamlWrite, {
|
||||
yamlPath,
|
||||
yamlBytes: yaml.length,
|
||||
templatePath: this.deps.templatePath,
|
||||
})
|
||||
|
||||
onLog?.('Creating BrowserOS VM...')
|
||||
logger.info(VM_TELEMETRY_EVENTS.provisionCreateStart, { yamlPath })
|
||||
const createStarted = Date.now()
|
||||
await this.cli.create(VM_NAME, yamlPath)
|
||||
logger.info(VM_TELEMETRY_EVENTS.provisionCreateOk, {
|
||||
durationMs: Date.now() - createStarted,
|
||||
})
|
||||
|
||||
onLog?.('Starting BrowserOS VM...')
|
||||
logger.info(VM_TELEMETRY_EVENTS.provisionStartBegin, {})
|
||||
const startStarted = Date.now()
|
||||
await this.cli.start(VM_NAME)
|
||||
logger.info(VM_TELEMETRY_EVENTS.provisionStartOk, {
|
||||
durationMs: Date.now() - startStarted,
|
||||
})
|
||||
}
|
||||
|
||||
private async recreateForContainerd(onLog?: LogFn): Promise<void> {
|
||||
onLog?.('Recreating BrowserOS VM for containerd runtime...')
|
||||
try {
|
||||
await this.cli.stop(VM_NAME)
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof LimaCommandError) ||
|
||||
!isAlreadyStopped(error.stderr)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
await this.cli.delete(VM_NAME)
|
||||
await this.provisionFresh(onLog)
|
||||
}
|
||||
|
||||
private async needsContainerdReprovision(): Promise<boolean> {
|
||||
const lines: string[] = []
|
||||
try {
|
||||
const exitCode = await this.runCommand(
|
||||
['sh', '-lc', 'cat /etc/browseros-vm-version 2>/dev/null || true'],
|
||||
{ onOutput: (line) => lines.push(line) },
|
||||
)
|
||||
if (exitCode !== 0) return false
|
||||
} catch (error) {
|
||||
logger.warn('Failed to inspect BrowserOS VM runtime marker', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return !lines.some((line) => line.trim() === ROOTLESS_CONTAINERD_MARKER)
|
||||
}
|
||||
|
||||
private async buildLimaYaml(): Promise<string> {
|
||||
if (!this.deps.templatePath) {
|
||||
throw new Error(
|
||||
'BrowserOS VM Lima template path is missing; configure VmRuntime with resourcesDir',
|
||||
)
|
||||
}
|
||||
|
||||
return renderLimaTemplate(await readFile(this.deps.templatePath, 'utf8'), {
|
||||
vmStateDir: getVmStateDir(this.deps.browserosRoot),
|
||||
imageCacheDir: getImageCacheDir(this.deps.browserosRoot),
|
||||
})
|
||||
}
|
||||
|
||||
private async waitForRootlessNerdctl(timeoutMs: number): Promise<void> {
|
||||
const started = Date.now()
|
||||
const deadline = started + timeoutMs
|
||||
logger.info(VM_TELEMETRY_EVENTS.nerdctlWaitStart, {
|
||||
timeoutMs,
|
||||
pollMs: this.readinessPollMs,
|
||||
})
|
||||
let pollCount = 0
|
||||
while (Date.now() < deadline) {
|
||||
pollCount += 1
|
||||
if (await this.isReady()) {
|
||||
logger.info(VM_TELEMETRY_EVENTS.nerdctlWaitOk, {
|
||||
pollCount,
|
||||
waitMs: Date.now() - started,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (pollCount === 1 || pollCount % 10 === 0) {
|
||||
logger.debug(VM_TELEMETRY_EVENTS.nerdctlWaitPoll, {
|
||||
pollCount,
|
||||
elapsedMs: Date.now() - started,
|
||||
})
|
||||
}
|
||||
await Bun.sleep(this.readinessPollMs)
|
||||
}
|
||||
logger.error(VM_TELEMETRY_EVENTS.nerdctlWaitTimeout, {
|
||||
timeoutMs,
|
||||
pollCount,
|
||||
})
|
||||
throw new VmNotReadyError('rootless nerdctl never became ready')
|
||||
}
|
||||
|
||||
private async isRootlessNerdctlReady(): Promise<boolean> {
|
||||
try {
|
||||
return (await this.runCommand(['nerdctl', 'info'])) === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function notImplemented(feature: string): VmError {
|
||||
return new VmError(
|
||||
`${feature} is not implemented yet - see WS4 follow-up plan`,
|
||||
)
|
||||
}
|
||||
|
||||
function isAlreadyStopped(stderr: string): boolean {
|
||||
const lower = stderr.toLowerCase()
|
||||
return (
|
||||
lower.includes('not running') ||
|
||||
lower.includes('already stopped') ||
|
||||
lower.includes('not found')
|
||||
)
|
||||
}
|
||||
|
||||
function parseDefaultGateway(output: string): string | null {
|
||||
return output.match(/\bdefault\s+via\s+(\d+\.\d+\.\d+\.\d+)\b/)?.[1] ?? null
|
||||
}
|
||||
@@ -15,9 +15,10 @@ import { EXIT_CODES } from '@browseros/shared/constants/exit-codes'
|
||||
import { createHttpServer } from './api/server'
|
||||
import {
|
||||
configureOpenClawService,
|
||||
configureVmRuntime,
|
||||
getOpenClawService,
|
||||
} from './api/services/openclaw/openclaw-service'
|
||||
import { 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'
|
||||
import type { ServerConfig } from './config'
|
||||
@@ -25,6 +26,7 @@ import { INLINED_ENV } from './env'
|
||||
import {
|
||||
cleanOldSessions,
|
||||
ensureBrowserosDir,
|
||||
getOpenClawDir,
|
||||
removeServerConfigSync,
|
||||
writeServerConfig,
|
||||
} from './lib/browseros-dir'
|
||||
@@ -60,7 +62,16 @@ export class Application {
|
||||
})
|
||||
|
||||
const resourcesDir = path.resolve(this.config.resourcesDir)
|
||||
configureVmRuntime({ resourcesDir })
|
||||
const podmanOverrides = await loadPodmanOverrides(getOpenClawDir())
|
||||
configurePodmanRuntime({
|
||||
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) {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { chmod, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export interface FakeLimactlResponse {
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
exit?: number
|
||||
}
|
||||
|
||||
export async function fakeLimactl(
|
||||
canned: Record<string, FakeLimactlResponse>,
|
||||
logPath?: string,
|
||||
): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'fake-limactl-'))
|
||||
const path = join(dir, 'limactl')
|
||||
const limaHomeExpansion = '$' + '{LIMA_HOME-}'
|
||||
const cases = Object.entries(canned)
|
||||
.map(([command, response]) =>
|
||||
[
|
||||
` ${JSON.stringify(command)})`,
|
||||
` echo "ARGS:$*" >> "${logPath ?? '/dev/null'}"`,
|
||||
` echo "LIMA_HOME:${limaHomeExpansion}" >> "${logPath ?? '/dev/null'}"`,
|
||||
` printf %b ${JSON.stringify(response.stdout ?? '')}`,
|
||||
` printf %b ${JSON.stringify(response.stderr ?? '')} >&2`,
|
||||
` exit ${response.exit ?? 0}`,
|
||||
' ;;',
|
||||
].join('\n'),
|
||||
)
|
||||
.join('\n')
|
||||
const body = `#!/usr/bin/env bash
|
||||
set -u
|
||||
case "$1" in
|
||||
${cases}
|
||||
*)
|
||||
echo "ARGS:$*" >> "${logPath ?? '/dev/null'}"
|
||||
echo "unexpected subcommand: $1" >&2
|
||||
exit 99
|
||||
;;
|
||||
esac
|
||||
`
|
||||
await writeFile(path, body)
|
||||
await chmod(path, 0o755)
|
||||
return path
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { chmod, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export interface FakeSshResponse {
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
exit?: number
|
||||
}
|
||||
|
||||
export async function fakeSsh(
|
||||
response: FakeSshResponse = {},
|
||||
logPath?: string,
|
||||
): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'fake-ssh-'))
|
||||
const path = join(dir, 'ssh')
|
||||
const body = `#!/usr/bin/env bash
|
||||
set -u
|
||||
echo "ARGS:$*" >> "${logPath ?? '/dev/null'}"
|
||||
printf %b ${JSON.stringify(response.stdout ?? '')}
|
||||
printf %b ${JSON.stringify(response.stderr ?? '')} >&2
|
||||
exit ${response.exit ?? 0}
|
||||
`
|
||||
await writeFile(path, body)
|
||||
await chmod(path, 0o755)
|
||||
return path
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { dirname, resolve } from 'node:path'
|
||||
const projectRoot = resolve(import.meta.dir, '..', '..')
|
||||
const testsRoot = resolve(projectRoot, 'tests')
|
||||
const cleanupScript = resolve(testsRoot, '__helpers__/cleanup.sh')
|
||||
const testPreloadPath = './tests/__helpers__/test-env.ts'
|
||||
const preferredDirectoryGroups = [
|
||||
'agent',
|
||||
'api',
|
||||
@@ -97,7 +96,7 @@ function runCommand(cmd: string[], label: string): number {
|
||||
console.log(`\n==> ${label}`)
|
||||
const result = spawnSync(cmd[0], cmd.slice(1), {
|
||||
cwd: projectRoot,
|
||||
env: withTestEnv(process.env),
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
@@ -108,30 +107,6 @@ function runCommand(cmd: string[], label: string): number {
|
||||
return result.status ?? 1
|
||||
}
|
||||
|
||||
export function withTestEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
if (env.NODE_ENV) return env
|
||||
return { ...env, NODE_ENV: 'test' }
|
||||
}
|
||||
|
||||
export function buildTestCommand(
|
||||
targets: string[],
|
||||
junitPath?: string,
|
||||
): string[] {
|
||||
const cmd = [
|
||||
process.execPath,
|
||||
'--env-file=.env.development',
|
||||
'test',
|
||||
`--preload=${testPreloadPath}`,
|
||||
]
|
||||
if (junitPath) {
|
||||
const outputPath = resolve(projectRoot, junitPath)
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
cmd.push('--reporter=junit', `--reporter-outfile=${outputPath}`)
|
||||
}
|
||||
cmd.push(...targets)
|
||||
return cmd
|
||||
}
|
||||
|
||||
function runAtomicGroup(group: string): number {
|
||||
const targets = getAtomicGroupTargets(group)
|
||||
if (targets.length === 0) {
|
||||
@@ -141,7 +116,13 @@ function runAtomicGroup(group: string): number {
|
||||
}
|
||||
runCommand(['bash', cleanupScript], `Cleaning up test resources for ${group}`)
|
||||
const junitPath = process.env.BROWSEROS_JUNIT_PATH?.trim()
|
||||
const cmd = buildTestCommand(targets, junitPath)
|
||||
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`)
|
||||
}
|
||||
|
||||
@@ -160,7 +141,6 @@ function runGroup(group: string): number {
|
||||
return runAtomicGroup(group)
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const requestedGroup = process.argv[2] ?? 'all'
|
||||
process.exit(runGroup(requestedGroup))
|
||||
}
|
||||
const requestedGroup = process.argv[2] ?? 'all'
|
||||
|
||||
process.exit(runGroup(requestedGroup))
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
process.env.NODE_ENV = 'test'
|
||||
@@ -4,7 +4,9 @@
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { OpenClawSessionNotFoundError } from '../../../src/api/services/openclaw/errors'
|
||||
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', () => {
|
||||
@@ -262,6 +264,124 @@ describe('createOpenClawRoutes', () => {
|
||||
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'
|
||||
@@ -314,124 +434,4 @@ describe('createOpenClawRoutes', () => {
|
||||
modelId: 'gpt-5.4-mini',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns JSON history from the session history route and forwards query params', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const getSessionHistory = mock(async () => ({
|
||||
sessionKey: 'agent:main:main',
|
||||
messages: [{ role: 'user', content: 'hi', messageSeq: 1 }],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
}))
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ getSessionHistory }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request(
|
||||
'/session/agent%3Amain%3Amain/history?limit=25&cursor=next',
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toContain('application/json')
|
||||
expect(getSessionHistory).toHaveBeenCalledWith('agent:main:main', {
|
||||
limit: 25,
|
||||
cursor: 'next',
|
||||
})
|
||||
expect(await response.json()).toEqual({
|
||||
sessionKey: 'agent:main:main',
|
||||
messages: [{ role: 'user', content: 'hi', messageSeq: 1 }],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 404 when the service reports a missing session', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const getSessionHistory = mock(async () => {
|
||||
throw new OpenClawSessionNotFoundError('missing')
|
||||
})
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ getSessionHistory }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/session/missing/history')
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'OpenClaw session not found: missing',
|
||||
})
|
||||
})
|
||||
|
||||
it('streams named SSE frames when Accept: text/event-stream', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const streamSessionHistory = mock(
|
||||
async () =>
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: 'history',
|
||||
data: {
|
||||
sessionKey: 'k',
|
||||
messages: [],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
controller.enqueue({
|
||||
type: 'message',
|
||||
data: {
|
||||
sessionKey: 'k',
|
||||
messageSeq: 2,
|
||||
message: { role: 'assistant', content: 'hi', messageSeq: 2 },
|
||||
},
|
||||
})
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ streamSessionHistory }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/session/k/history', {
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toContain('text/event-stream')
|
||||
expect(response.headers.get('X-Session-Key')).toBe('k')
|
||||
expect(streamSessionHistory).toHaveBeenCalledTimes(1)
|
||||
expect(streamSessionHistory.mock.calls[0]?.[0]).toBe('k')
|
||||
expect(await response.text()).toBe(
|
||||
'event: history\ndata: {"sessionKey":"k","messages":[],"cursor":null,"hasMore":false}\n\n' +
|
||||
'event: message\ndata: {"sessionKey":"k","messageSeq":2,"message":{"role":"assistant","content":"hi","messageSeq":2}}\n\n',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
serializeTerminalServerMessage,
|
||||
} from '../../../src/api/services/terminal/terminal-protocol'
|
||||
import {
|
||||
buildTerminalEnv,
|
||||
buildTerminalExecCommand,
|
||||
TERMINAL_HOME_DIR,
|
||||
} from '../../../src/api/services/terminal/terminal-session'
|
||||
@@ -51,20 +50,15 @@ describe('terminal protocol', () => {
|
||||
).toBe('{"type":"output","data":"hello"}')
|
||||
})
|
||||
|
||||
it('builds a limactl shell command rooted in the container home dir', () => {
|
||||
it('builds a podman exec command rooted in the container home dir', () => {
|
||||
expect(
|
||||
buildTerminalExecCommand(
|
||||
'limactl',
|
||||
'browseros-vm',
|
||||
'podman',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
TERMINAL_HOME_DIR,
|
||||
),
|
||||
).toEqual([
|
||||
'limactl',
|
||||
'shell',
|
||||
'browseros-vm',
|
||||
'--',
|
||||
'nerdctl',
|
||||
'podman',
|
||||
'exec',
|
||||
'-it',
|
||||
'-w',
|
||||
@@ -73,13 +67,4 @@ describe('terminal protocol', () => {
|
||||
'/bin/sh',
|
||||
])
|
||||
})
|
||||
|
||||
it('sets LIMA_HOME for terminal limactl sessions', () => {
|
||||
expect(buildTerminalEnv('/tmp/browseros-lima')).toEqual(
|
||||
expect.objectContaining({
|
||||
LIMA_HOME: '/tmp/browseros-lima',
|
||||
TERM: 'xterm-256color',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
buildContainerRuntime,
|
||||
migrateLegacyOpenClawDir,
|
||||
} from '../../../../src/api/services/openclaw/container-runtime-factory'
|
||||
import { logger } from '../../../../src/lib/logger'
|
||||
|
||||
describe('container-runtime factory', () => {
|
||||
let root: string
|
||||
let resourcesDir: string
|
||||
let originalNodeEnv: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp('/tmp/openclaw-runtime-factory-')
|
||||
resourcesDir = join(root, 'resources')
|
||||
await mkdir(join(resourcesDir, 'bin', 'third_party', 'lima'), {
|
||||
recursive: true,
|
||||
})
|
||||
await mkdir(join(resourcesDir, 'vm'), { recursive: true })
|
||||
await writeFile(
|
||||
join(resourcesDir, 'bin', 'third_party', 'lima', 'limactl'),
|
||||
'#!/bin/sh\n',
|
||||
)
|
||||
await writeFile(
|
||||
join(resourcesDir, 'vm', 'browseros-vm.yaml'),
|
||||
'mounts: []\n',
|
||||
)
|
||||
originalNodeEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'production'
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV
|
||||
} else {
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
}
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rejects non-macOS platforms', () => {
|
||||
expect(() =>
|
||||
buildContainerRuntime({
|
||||
resourcesDir,
|
||||
projectDir: join(root, 'project'),
|
||||
browserosRoot: root,
|
||||
platform: 'linux',
|
||||
}),
|
||||
).toThrow('supports macOS only')
|
||||
})
|
||||
|
||||
it('returns a disabled runtime on non-macOS platforms in test mode', async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
|
||||
const runtime = buildContainerRuntime({
|
||||
resourcesDir,
|
||||
projectDir: join(root, 'project'),
|
||||
browserosRoot: root,
|
||||
platform: 'linux',
|
||||
})
|
||||
|
||||
await expect(runtime.getMachineStatus()).resolves.toEqual({
|
||||
initialized: false,
|
||||
running: false,
|
||||
})
|
||||
await expect(runtime.ensureReady()).rejects.toThrow('supports macOS only')
|
||||
await expect(runtime.stopVm()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('migrates legacy OpenClaw state into the VM state directory', async () => {
|
||||
const legacyFile = join(root, 'openclaw', '.openclaw', 'openclaw.json')
|
||||
await mkdir(dirname(legacyFile), { recursive: true })
|
||||
await writeFile(legacyFile, '{"ok":true}\n')
|
||||
|
||||
await migrateLegacyOpenClawDir(root)
|
||||
|
||||
await expect(
|
||||
readFile(
|
||||
join(root, 'vm', 'openclaw', '.openclaw', 'openclaw.json'),
|
||||
'utf8',
|
||||
),
|
||||
).resolves.toBe('{"ok":true}\n')
|
||||
await expect(readFile(legacyFile, 'utf8')).resolves.toBe('{"ok":true}\n')
|
||||
})
|
||||
|
||||
it('leaves both directories in place when new OpenClaw state already exists', async () => {
|
||||
const legacyFile = join(root, 'openclaw', 'legacy.txt')
|
||||
const newFile = join(root, 'vm', 'openclaw', 'new.txt')
|
||||
await mkdir(dirname(legacyFile), { recursive: true })
|
||||
await mkdir(dirname(newFile), { recursive: true })
|
||||
await writeFile(legacyFile, 'legacy')
|
||||
await writeFile(newFile, 'new')
|
||||
const originalWarn = logger.warn
|
||||
const warnings: string[] = []
|
||||
logger.warn = (message) => warnings.push(message)
|
||||
|
||||
try {
|
||||
await migrateLegacyOpenClawDir(root)
|
||||
} finally {
|
||||
logger.warn = originalWarn
|
||||
}
|
||||
|
||||
await expect(readFile(legacyFile, 'utf8')).resolves.toBe('legacy')
|
||||
await expect(readFile(newFile, 'utf8')).resolves.toBe('new')
|
||||
expect(warnings).toContain(
|
||||
'OpenClaw legacy and VM state directories both exist',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it, mock } from 'bun:test'
|
||||
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'
|
||||
|
||||
@@ -11,85 +11,135 @@ const PROJECT_DIR = '/tmp/openclaw'
|
||||
const defaultSpec = {
|
||||
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
hostPort: 18789,
|
||||
hostHome: '/Users/me/.browseros/vm/openclaw',
|
||||
envFilePath: '/Users/me/.browseros/vm/openclaw/.openclaw/.env',
|
||||
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('starts the gateway by loading the image, creating, and starting a container', async () => {
|
||||
const deps = createDeps()
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
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(deps.shell.removeContainer).toHaveBeenCalledWith(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
{ force: true },
|
||||
undefined,
|
||||
)
|
||||
expect(deps.loader.ensureImageLoaded).toHaveBeenCalledWith(
|
||||
defaultSpec.image,
|
||||
undefined,
|
||||
)
|
||||
expect(deps.shell.createContainer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
image: defaultSpec.image,
|
||||
restart: 'unless-stopped',
|
||||
ports: [
|
||||
{
|
||||
hostIp: '127.0.0.1',
|
||||
hostPort: 18789,
|
||||
containerPort: 18789,
|
||||
},
|
||||
],
|
||||
envFile: '/mnt/browseros/vm/openclaw/.openclaw/.env',
|
||||
mounts: [
|
||||
{
|
||||
source: '/mnt/browseros/vm/openclaw',
|
||||
target: '/home/node',
|
||||
},
|
||||
],
|
||||
addHosts: ['host.containers.internal:192.168.5.2'],
|
||||
}),
|
||||
undefined,
|
||||
)
|
||||
expect(deps.shell.startContainer).toHaveBeenCalledWith(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
)
|
||||
})
|
||||
|
||||
it('delegates ensureReady and stopVm to VmRuntime', async () => {
|
||||
const deps = createDeps()
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
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),
|
||||
})
|
||||
|
||||
await runtime.ensureReady()
|
||||
await runtime.stopVm()
|
||||
|
||||
expect(deps.vm.ensureReady).toHaveBeenCalled()
|
||||
expect(deps.vm.getDefaultGateway).toHaveBeenCalled()
|
||||
expect(deps.vm.stopVm).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs setup commands with guest paths', async () => {
|
||||
const deps = createDeps()
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
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(
|
||||
@@ -97,80 +147,180 @@ describe('ContainerRuntime', () => {
|
||||
defaultSpec,
|
||||
)
|
||||
|
||||
expect(deps.shell.runCommand).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
'create',
|
||||
'--name',
|
||||
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
|
||||
'--env-file',
|
||||
'/mnt/browseros/vm/openclaw/.openclaw/.env',
|
||||
'-v',
|
||||
'/mnt/browseros/vm/openclaw:/home/node',
|
||||
'--add-host',
|
||||
'host.containers.internal:192.168.5.2',
|
||||
]),
|
||||
undefined,
|
||||
)
|
||||
expect(deps.shell.runCommand).toHaveBeenCalledWith(
|
||||
['start', '-a', `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`],
|
||||
undefined,
|
||||
)
|
||||
expect(deps.shell.removeContainer).toHaveBeenCalledWith(
|
||||
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
|
||||
{ force: true },
|
||||
undefined,
|
||||
)
|
||||
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('tails and fetches gateway logs through the new transport', async () => {
|
||||
const deps = createDeps()
|
||||
const runtime = new ContainerRuntime({
|
||||
vm: deps.vm,
|
||||
shell: deps.shell,
|
||||
loader: deps.loader,
|
||||
projectDir: PROJECT_DIR,
|
||||
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(() => {})
|
||||
const logs = await runtime.getGatewayLogs(10)
|
||||
stop()
|
||||
|
||||
expect(deps.shell.tailLogs).toHaveBeenCalledWith(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(deps.shell.runCommand).toHaveBeenCalledWith(
|
||||
['logs', '-n', '10', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(logs).toEqual(['log line'])
|
||||
expect(names).toEqual([OPENCLAW_GATEWAY_CONTAINER_NAME])
|
||||
})
|
||||
})
|
||||
|
||||
function createDeps() {
|
||||
return {
|
||||
vm: {
|
||||
ensureReady: mock(async () => {}),
|
||||
getDefaultGateway: mock(async () => '192.168.5.2'),
|
||||
stopVm: mock(async () => {}),
|
||||
isReady: mock(async () => true),
|
||||
},
|
||||
shell: {
|
||||
createContainer: mock(async () => {}),
|
||||
startContainer: mock(async () => {}),
|
||||
stopContainer: mock(async () => {}),
|
||||
removeContainer: mock(async () => {}),
|
||||
exec: mock(async () => 0),
|
||||
runCommand: mock(
|
||||
async (_args: string[], onLog?: (line: string) => void) => {
|
||||
onLog?.('log line')
|
||||
return { exitCode: 0, stdout: 'log line\n', stderr: '' }
|
||||
},
|
||||
),
|
||||
tailLogs: mock(() => () => {}),
|
||||
},
|
||||
loader: {
|
||||
ensureImageLoaded: mock(async () => {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,516 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { OpenClawSessionNotFoundError } from '../../../../src/api/services/openclaw/errors'
|
||||
import { OpenClawHttpClient } from '../../../../src/api/services/openclaw/openclaw-http-client'
|
||||
|
||||
describe('OpenClawHttpClient', () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
it('maps chat completion deltas into BrowserOS stream events', async () => {
|
||||
const fetchMock = mock((_url: string | URL, _init?: RequestInit) =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
const stream = await client.streamChat({
|
||||
agentId: 'research',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
history: [{ role: 'assistant', content: 'Earlier reply' }],
|
||||
})
|
||||
|
||||
const events = await readEvents(stream)
|
||||
const call = fetchMock.mock.calls[0]
|
||||
|
||||
expect(call?.[0]).toBe('http://127.0.0.1:18789/v1/chat/completions')
|
||||
expect(call?.[1]).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer gateway-token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
expect(JSON.parse(String(call?.[1]?.body))).toEqual({
|
||||
model: 'openclaw/research',
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'assistant', content: 'Earlier reply' },
|
||||
{ role: 'user', content: 'hi' },
|
||||
],
|
||||
user: 'browseros:research:session-123',
|
||||
})
|
||||
expect(events).toEqual([
|
||||
{ type: 'text-delta', data: { text: 'Hello' } },
|
||||
{ type: 'text-delta', data: { text: ' world' } },
|
||||
{ type: 'done', data: { text: 'Hello world' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses openclaw for the main agent', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await client.streamChat({
|
||||
agentId: 'main',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
})
|
||||
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as {
|
||||
model: string
|
||||
}
|
||||
expect(body.model).toBe('openclaw')
|
||||
})
|
||||
|
||||
it('throws on non-success HTTP responses', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('Unauthorized', { status: 401 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await expect(
|
||||
client.streamChat({
|
||||
agentId: 'research',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
}),
|
||||
).rejects.toThrow('Unauthorized')
|
||||
})
|
||||
|
||||
it('checks gateway authentication with the current bearer token', async () => {
|
||||
const fetchMock = mock(() => Promise.resolve(new Response('{}')))
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await expect(client.isAuthenticated()).resolves.toBe(true)
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'http://127.0.0.1:18789/v1/models',
|
||||
)
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Bearer gateway-token',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('treats rejected gateway authentication as unavailable', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('Unauthorized', { status: 401 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await expect(client.isAuthenticated()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('treats failed gateway authentication probes as unavailable', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.reject(new Error('connect ECONNREFUSED')),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await expect(client.isAuthenticated()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces an error when OpenClaw finishes without assistant text', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
const stream = await client.streamChat({
|
||||
agentId: 'main',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
})
|
||||
|
||||
await expect(readEvents(stream)).resolves.toEqual([
|
||||
{
|
||||
type: 'error',
|
||||
data: {
|
||||
message: "Agent couldn't generate a response. Please try again.",
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stops processing batched SSE events after a malformed chunk closes the stream', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n' +
|
||||
'data: not-json\n\n' +
|
||||
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
|
||||
),
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
const stream = await client.streamChat({
|
||||
agentId: 'research',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
})
|
||||
|
||||
await expect(readEvents(stream)).resolves.toEqual([
|
||||
{ type: 'text-delta', data: { text: 'Hello' } },
|
||||
{
|
||||
type: 'error',
|
||||
data: { message: 'Failed to parse OpenClaw chat stream chunk' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe('getSessionHistory', () => {
|
||||
it('sends GET with bearer auth and forwards limit/cursor as query params', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
sessionKey: 'agent:main:main',
|
||||
messages: [
|
||||
{ role: 'user', content: 'hi', messageSeq: 1 },
|
||||
{ role: 'assistant', content: 'hello', messageSeq: 2 },
|
||||
],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
const result = await client.getSessionHistory('agent:main:main', {
|
||||
limit: 50,
|
||||
cursor: 'abc',
|
||||
})
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'http://127.0.0.1:18789/sessions/agent%3Amain%3Amain/history?limit=50&cursor=abc',
|
||||
)
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer gateway-token' },
|
||||
})
|
||||
expect(result).toEqual({
|
||||
sessionKey: 'agent:main:main',
|
||||
messages: [
|
||||
{ role: 'user', content: 'hi', messageSeq: 1 },
|
||||
{ role: 'assistant', content: 'hello', messageSeq: 2 },
|
||||
],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits limit and cursor from the query when undefined', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ sessionKey: 'k', messages: [] }), {
|
||||
status: 200,
|
||||
}),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await client.getSessionHistory('k')
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'http://127.0.0.1:18789/sessions/k/history',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws OpenClawSessionNotFoundError on 404', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('not found', { status: 404 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await expect(
|
||||
client.getSessionHistory('missing-key'),
|
||||
).rejects.toBeInstanceOf(OpenClawSessionNotFoundError)
|
||||
})
|
||||
|
||||
it('surfaces the response body on other non-2xx responses', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('boom', { status: 500 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await expect(client.getSessionHistory('k')).rejects.toThrow('boom')
|
||||
})
|
||||
|
||||
it('propagates the abort signal to fetch', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ sessionKey: 'k', messages: [] }), {
|
||||
status: 200,
|
||||
}),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const controller = new AbortController()
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await client.getSessionHistory('k', { signal: controller.signal })
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.signal).toBe(controller.signal)
|
||||
})
|
||||
})
|
||||
|
||||
describe('streamSessionHistory', () => {
|
||||
it('parses named history/message SSE events into typed events', async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'event: history\ndata: {"sessionKey":"k","messages":[{"role":"user","content":"hi","messageSeq":1}],"cursor":null,"hasMore":false}\n\n',
|
||||
),
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'event: message\ndata: {"sessionKey":"k","messageSeq":2,"message":{"role":"assistant","content":"hey","messageSeq":2}}\n\n',
|
||||
),
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
const stream = await client.streamSessionHistory('k', { limit: 20 })
|
||||
|
||||
const events = await readEvents(stream)
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'http://127.0.0.1:18789/sessions/k/history?limit=20',
|
||||
)
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
Authorization: 'Bearer gateway-token',
|
||||
},
|
||||
})
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: 'history',
|
||||
data: {
|
||||
sessionKey: 'k',
|
||||
messages: [{ role: 'user', content: 'hi', messageSeq: 1 }],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
data: {
|
||||
sessionKey: 'k',
|
||||
messageSeq: 2,
|
||||
message: { role: 'assistant', content: 'hey', messageSeq: 2 },
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards upstream error frames and closes', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'event: error\ndata: {"message":"upstream exploded"}\n\n',
|
||||
),
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
const stream = await client.streamSessionHistory('k')
|
||||
|
||||
await expect(readEvents(stream)).resolves.toEqual([
|
||||
{ type: 'error', data: { message: 'upstream exploded' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('throws OpenClawSessionNotFoundError on 404', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response('not found', { status: 404 })),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
await expect(client.streamSessionHistory('k')).rejects.toBeInstanceOf(
|
||||
OpenClawSessionNotFoundError,
|
||||
)
|
||||
})
|
||||
|
||||
it('closes when the abort signal fires mid-stream', async () => {
|
||||
const ac = new AbortController()
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'event: history\ndata: {"sessionKey":"k","messages":[]}\n\n',
|
||||
),
|
||||
)
|
||||
// Keep the stream open; abort should close it from our side.
|
||||
await new Promise((resolve) => {
|
||||
ac.signal.addEventListener(
|
||||
'abort',
|
||||
() => resolve(undefined),
|
||||
{
|
||||
once: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
) as typeof globalThis.fetch
|
||||
const client = new OpenClawHttpClient(18789, async () => 'gateway-token')
|
||||
|
||||
const stream = await client.streamSessionHistory('k', {
|
||||
signal: ac.signal,
|
||||
})
|
||||
const reader = stream.getReader()
|
||||
const first = await reader.read()
|
||||
expect(first.done).toBe(false)
|
||||
expect(first.value).toMatchObject({ type: 'history' })
|
||||
|
||||
ac.abort()
|
||||
const next = await reader.read()
|
||||
expect(next.done).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
async function readEvents(
|
||||
stream: ReadableStream<{ type: string; data: Record<string, unknown> }>,
|
||||
): Promise<Array<{ type: string; data: Record<string, unknown> }>> {
|
||||
const reader = stream.getReader()
|
||||
const events: Array<{ type: string; data: Record<string, unknown> }> = []
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
events.push(value)
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
@@ -41,7 +41,7 @@ type MutableOpenClawService = OpenClawService & {
|
||||
stopGateway?: (_onLog?: (_line: string) => void) => Promise<void>
|
||||
getGatewayLogs?: (_tail?: number) => Promise<string[]>
|
||||
waitForReady?: () => Promise<boolean>
|
||||
stopVm?: () => Promise<void>
|
||||
stopMachineIfSafe?: () => Promise<void>
|
||||
}
|
||||
cliClient: {
|
||||
probe?: ReturnType<typeof mock>
|
||||
@@ -60,11 +60,9 @@ type MutableOpenClawService = OpenClawService & {
|
||||
|
||||
describe('OpenClawService', () => {
|
||||
let tempDir: string | null = null
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
globalThis.fetch = originalFetch
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
tempDir = null
|
||||
@@ -214,6 +212,9 @@ describe('OpenClawService', () => {
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
const pullImage = mock(async () => {
|
||||
steps.push('pull')
|
||||
})
|
||||
const restartGateway = mock(async () => {
|
||||
steps.push('restart')
|
||||
})
|
||||
@@ -224,6 +225,7 @@ describe('OpenClawService', () => {
|
||||
isPodmanAvailable: async () => true,
|
||||
ensureReady: async () => {},
|
||||
isReady: async () => true,
|
||||
pullImage,
|
||||
restartGateway,
|
||||
startGateway,
|
||||
waitForReady: mock(async () => {
|
||||
@@ -277,7 +279,18 @@ describe('OpenClawService', () => {
|
||||
name: 'main',
|
||||
model: undefined,
|
||||
})
|
||||
expect(steps).toEqual(['onboard', 'batch', 'validate', 'start', 'ready'])
|
||||
expect(steps).toEqual([
|
||||
'pull',
|
||||
'onboard',
|
||||
'batch',
|
||||
'validate',
|
||||
'start',
|
||||
'ready',
|
||||
])
|
||||
expect(pullImage).toHaveBeenCalledWith(
|
||||
'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(startGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
@@ -629,7 +642,6 @@ describe('OpenClawService', () => {
|
||||
service.cliClient = {
|
||||
probe,
|
||||
}
|
||||
mockGatewayAuth()
|
||||
|
||||
const firstStart = service.start()
|
||||
await startGatewayEntered
|
||||
@@ -672,7 +684,6 @@ describe('OpenClawService', () => {
|
||||
service.cliClient = {
|
||||
probe,
|
||||
}
|
||||
mockGatewayAuth()
|
||||
|
||||
await service.start()
|
||||
|
||||
@@ -695,7 +706,6 @@ describe('OpenClawService', () => {
|
||||
},
|
||||
}),
|
||||
)
|
||||
const ensureReady = mock(async () => {})
|
||||
const restartGateway = mock(async () => {})
|
||||
const waitForReady = mock(async () => true)
|
||||
const probe = mock(async () => {})
|
||||
@@ -703,7 +713,6 @@ describe('OpenClawService', () => {
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady: async () => true,
|
||||
restartGateway,
|
||||
waitForReady,
|
||||
@@ -711,11 +720,9 @@ describe('OpenClawService', () => {
|
||||
service.cliClient = {
|
||||
probe,
|
||||
}
|
||||
mockGatewayAuth()
|
||||
|
||||
await service.restart()
|
||||
|
||||
expect(ensureReady).toHaveBeenCalledTimes(1)
|
||||
expect(restartGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
@@ -759,7 +766,6 @@ describe('OpenClawService', () => {
|
||||
join(tempDir, '.openclaw', 'runtime-state.json'),
|
||||
`${JSON.stringify({ gatewayPort: occupiedPort }, null, 2)}\n`,
|
||||
)
|
||||
const ensureReady = mock(async () => {})
|
||||
const restartGateway = mock(async () => {})
|
||||
const waitForReady = mock(async () => true)
|
||||
const probe = mock(async () => {})
|
||||
@@ -767,7 +773,6 @@ describe('OpenClawService', () => {
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady: async (hostPort?: number) => hostPort === occupiedPort,
|
||||
restartGateway,
|
||||
waitForReady,
|
||||
@@ -775,7 +780,6 @@ describe('OpenClawService', () => {
|
||||
service.cliClient = {
|
||||
probe,
|
||||
}
|
||||
mockGatewayAuth()
|
||||
|
||||
try {
|
||||
await service.restart()
|
||||
@@ -797,80 +801,6 @@ describe('OpenClawService', () => {
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(ensureReady).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('restart moves off a persisted ready port when auth rejects the current token', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: 'cli-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const occupiedServer = createServer()
|
||||
const occupiedPort = await new Promise<number>((resolve, reject) => {
|
||||
occupiedServer.once('error', reject)
|
||||
occupiedServer.listen(0, '127.0.0.1', () => {
|
||||
const address = occupiedServer.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
reject(new Error('failed to allocate test port'))
|
||||
return
|
||||
}
|
||||
resolve(address.port)
|
||||
})
|
||||
})
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'runtime-state.json'),
|
||||
`${JSON.stringify({ gatewayPort: occupiedPort }, null, 2)}\n`,
|
||||
)
|
||||
const ensureReady = mock(async () => {})
|
||||
const restartGateway = mock(async () => {})
|
||||
const waitForReady = mock(async () => true)
|
||||
const probe = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady: async (hostPort?: number) => hostPort === occupiedPort,
|
||||
restartGateway,
|
||||
waitForReady,
|
||||
}
|
||||
service.cliClient = {
|
||||
probe,
|
||||
}
|
||||
mockGatewayAuth(401)
|
||||
|
||||
try {
|
||||
await service.restart()
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
occupiedServer.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
expect(restartGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hostPort: expect.any(Number),
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(
|
||||
(restartGateway.mock.calls[0]?.[0] as { hostPort: number }).hostPort,
|
||||
).not.toBe(occupiedPort)
|
||||
expect(ensureReady).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stop calls runtime.stopGateway', async () => {
|
||||
@@ -900,40 +830,40 @@ describe('OpenClawService', () => {
|
||||
expect(getGatewayLogs).toHaveBeenCalledWith(25)
|
||||
})
|
||||
|
||||
it('shutdown stops gateway and then stops the VM', async () => {
|
||||
it('shutdown stops gateway and then stops machine when safe', async () => {
|
||||
const stopGateway = mock(async () => {})
|
||||
const stopVm = mock(async () => {})
|
||||
const stopMachineIfSafe = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
stopGateway,
|
||||
stopVm,
|
||||
stopMachineIfSafe,
|
||||
}
|
||||
|
||||
await service.shutdown()
|
||||
|
||||
expect(stopGateway).toHaveBeenCalledTimes(1)
|
||||
expect(stopVm).toHaveBeenCalledTimes(1)
|
||||
expect(stopMachineIfSafe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shutdown still stops the VM when stopGateway fails', async () => {
|
||||
it('shutdown still stops machine when stopGateway fails', async () => {
|
||||
const stopGateway = mock(async () => {
|
||||
throw new Error('stop failed')
|
||||
})
|
||||
const stopVm = mock(async () => {})
|
||||
const stopMachineIfSafe = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.runtime = {
|
||||
isReady: async () => true,
|
||||
stopGateway,
|
||||
stopVm,
|
||||
stopMachineIfSafe,
|
||||
}
|
||||
|
||||
await expect(service.shutdown()).resolves.toBeUndefined()
|
||||
|
||||
expect(stopGateway).toHaveBeenCalledTimes(1)
|
||||
expect(stopVm).toHaveBeenCalledTimes(1)
|
||||
expect(stopMachineIfSafe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('tryAutoStart uses direct-runtime startGateway when gateway is not ready', async () => {
|
||||
@@ -1493,10 +1423,61 @@ describe('OpenClawService', () => {
|
||||
'OPENAI_API_KEY=sk-test\n',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function mockGatewayAuth(status = 200): ReturnType<typeof mock> {
|
||||
const fetchMock = mock(() => Promise.resolve(new Response('', { status })))
|
||||
globalThis.fetch = fetchMock as typeof globalThis.fetch
|
||||
return fetchMock
|
||||
}
|
||||
it('applyPodmanOverrides persists the override and refreshes the runtime', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
service.openclawDir = tempDir
|
||||
|
||||
const result = await service.applyPodmanOverrides({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
|
||||
expect(result.podmanPath).toBe('/opt/homebrew/bin/podman')
|
||||
expect(result.effectivePodmanPath).toBe('/opt/homebrew/bin/podman')
|
||||
|
||||
const persisted = JSON.parse(
|
||||
await readFile(join(tempDir, 'podman-overrides.json'), 'utf-8'),
|
||||
)
|
||||
expect(persisted).toEqual({ podmanPath: '/opt/homebrew/bin/podman' })
|
||||
|
||||
const reloaded = await service.getPodmanOverrides()
|
||||
expect(reloaded.podmanPath).toBe('/opt/homebrew/bin/podman')
|
||||
expect(reloaded.effectivePodmanPath).toBe('/opt/homebrew/bin/podman')
|
||||
})
|
||||
|
||||
it('applyPodmanOverrides with null clears the override and falls back', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const service = new OpenClawService({
|
||||
resourcesDir: tempDir,
|
||||
}) as MutableOpenClawService
|
||||
service.openclawDir = tempDir
|
||||
|
||||
await service.applyPodmanOverrides({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
const cleared = await service.applyPodmanOverrides({ podmanPath: null })
|
||||
|
||||
expect(cleared.podmanPath).toBeNull()
|
||||
// resourcesDir has no bundled binary, so the runtime falls through to 'podman'
|
||||
expect(cleared.effectivePodmanPath).toBe('podman')
|
||||
|
||||
const persisted = JSON.parse(
|
||||
await readFile(join(tempDir, 'podman-overrides.json'), 'utf-8'),
|
||||
)
|
||||
expect(persisted).toEqual({ podmanPath: null })
|
||||
})
|
||||
|
||||
it('applyPodmanOverrides rebuilds ContainerRuntime so it picks up the new Podman reference', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
service.openclawDir = tempDir
|
||||
|
||||
const before = service.runtime
|
||||
await service.applyPodmanOverrides({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
|
||||
expect(service.runtime).not.toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
configurePodmanRuntime,
|
||||
getPodmanRuntime,
|
||||
PodmanRuntime,
|
||||
readMachineResources,
|
||||
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
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browseros-podman-test-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
configurePodmanRuntime({ podmanPath: 'podman' })
|
||||
})
|
||||
|
||||
it('returns the bundled podman path when the executable exists', () => {
|
||||
const bundledPath = path.join(
|
||||
tempDir,
|
||||
'bin',
|
||||
'third_party',
|
||||
'podman',
|
||||
'podman',
|
||||
)
|
||||
fs.mkdirSync(path.dirname(bundledPath), { recursive: true })
|
||||
fs.writeFileSync(bundledPath, 'podman')
|
||||
|
||||
expect(resolveBundledPodmanPath(tempDir, 'darwin')).toBe(bundledPath)
|
||||
})
|
||||
|
||||
it('uses the windows executable name for bundled podman', () => {
|
||||
const bundledPath = path.join(
|
||||
tempDir,
|
||||
'bin',
|
||||
'third_party',
|
||||
'podman',
|
||||
'podman.exe',
|
||||
)
|
||||
fs.mkdirSync(path.dirname(bundledPath), { recursive: true })
|
||||
fs.writeFileSync(bundledPath, 'podman')
|
||||
|
||||
expect(resolveBundledPodmanPath(tempDir, 'win32')).toBe(bundledPath)
|
||||
})
|
||||
|
||||
it('returns null when no bundled podman executable exists', () => {
|
||||
expect(resolveBundledPodmanPath(tempDir, 'darwin')).toBeNull()
|
||||
})
|
||||
|
||||
it('configures the runtime to prefer the bundled podman path', () => {
|
||||
const bundledPath = path.join(
|
||||
tempDir,
|
||||
'bin',
|
||||
'third_party',
|
||||
'podman',
|
||||
'podman',
|
||||
)
|
||||
fs.mkdirSync(path.dirname(bundledPath), { recursive: true })
|
||||
fs.writeFileSync(bundledPath, 'podman')
|
||||
|
||||
const runtime = configurePodmanRuntime({ resourcesDir: tempDir })
|
||||
|
||||
expect(runtime.getPodmanPath()).toBe(bundledPath)
|
||||
expect(getPodmanRuntime().getPodmanPath()).toBe(bundledPath)
|
||||
})
|
||||
|
||||
it('falls back to PATH podman when no bundled executable is present', () => {
|
||||
const runtime = configurePodmanRuntime({ resourcesDir: tempDir })
|
||||
|
||||
expect(runtime.getPodmanPath()).toBe('podman')
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readMachineResources', () => {
|
||||
const ORIGINAL_CPUS = process.env.BROWSEROS_PODMAN_CPUS
|
||||
const ORIGINAL_MEMORY = process.env.BROWSEROS_PODMAN_MEMORY_MB
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv('BROWSEROS_PODMAN_CPUS', ORIGINAL_CPUS)
|
||||
restoreEnv('BROWSEROS_PODMAN_MEMORY_MB', ORIGINAL_MEMORY)
|
||||
})
|
||||
|
||||
it('returns defaults when env vars are unset', () => {
|
||||
delete process.env.BROWSEROS_PODMAN_CPUS
|
||||
delete process.env.BROWSEROS_PODMAN_MEMORY_MB
|
||||
|
||||
expect(readMachineResources()).toEqual({ cpus: 4, memoryMb: 4096 })
|
||||
})
|
||||
|
||||
it('parses valid env values', () => {
|
||||
process.env.BROWSEROS_PODMAN_CPUS = '6'
|
||||
process.env.BROWSEROS_PODMAN_MEMORY_MB = '8192'
|
||||
|
||||
expect(readMachineResources()).toEqual({ cpus: 6, memoryMb: 8192 })
|
||||
})
|
||||
|
||||
it('falls back to defaults for non-numeric input', () => {
|
||||
process.env.BROWSEROS_PODMAN_CPUS = 'abc'
|
||||
process.env.BROWSEROS_PODMAN_MEMORY_MB = ''
|
||||
|
||||
expect(readMachineResources()).toEqual({ cpus: 4, memoryMb: 4096 })
|
||||
})
|
||||
|
||||
it('falls back to defaults for zero and negative values', () => {
|
||||
process.env.BROWSEROS_PODMAN_CPUS = '0'
|
||||
process.env.BROWSEROS_PODMAN_MEMORY_MB = '-512'
|
||||
|
||||
expect(readMachineResources()).toEqual({ cpus: 4, memoryMb: 4096 })
|
||||
})
|
||||
})
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
@@ -8,10 +8,7 @@ import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import {
|
||||
getAgentCacheDir,
|
||||
getBrowserosDir,
|
||||
getCacheDir,
|
||||
getVmCacheDir,
|
||||
logDevelopmentBrowserosDir,
|
||||
} from '../src/lib/browseros-dir'
|
||||
import { logger } from '../src/lib/logger'
|
||||
@@ -75,34 +72,4 @@ describe('getBrowserosDir', () => {
|
||||
logger.info = originalInfo
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the development cache directory in development', () => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
|
||||
expect(getCacheDir()).toBe(join(homedir(), '.browseros-dev', 'cache'))
|
||||
})
|
||||
|
||||
it('uses the standard cache directory outside development', () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
|
||||
expect(getCacheDir()).toBe(
|
||||
join(homedir(), PATHS.BROWSEROS_DIR_NAME, 'cache'),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a vm cache directory below cache', () => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
|
||||
expect(getVmCacheDir()).toBe(
|
||||
join(homedir(), '.browseros-dev', 'cache', 'vm'),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses an agent image cache directory below vm cache', () => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
|
||||
expect(getAgentCacheDir()).toBe(
|
||||
join(homedir(), '.browseros-dev', 'cache', 'vm', 'images'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { ContainerCli } from '../../src/lib/container'
|
||||
import { LimaCli, type VmManifest, VmRuntime } from '../../src/lib/vm'
|
||||
import {
|
||||
getCachedManifestPath,
|
||||
getContainerdSocketPath,
|
||||
VM_NAME,
|
||||
} from '../../src/lib/vm/paths'
|
||||
|
||||
const LIVE_VM_SMOKE_TIMEOUT_MS = 10 * 60 * 1000
|
||||
const liveIt = process.env.LIVE_VM_SMOKE === '1' ? it : it.skip
|
||||
const limactlPath = process.env.LIMACTL_PATH ?? 'limactl'
|
||||
const templatePath = resolve(
|
||||
import.meta.dir,
|
||||
'../../../../packages/build-tools/template/browseros-vm.yaml',
|
||||
)
|
||||
|
||||
const manifest: VmManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents: {},
|
||||
}
|
||||
|
||||
describe('BrowserOS VM live smoke', () => {
|
||||
let root: string
|
||||
let limaHome: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp('/tmp/bovm-')
|
||||
limaHome = join(root, 'lima')
|
||||
const manifestPath = getCachedManifestPath(root)
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (process.env.LIVE_VM_SMOKE === '1') {
|
||||
await new LimaCli({ limactlPath, limaHome })
|
||||
.delete(VM_NAME)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
liveIt(
|
||||
'creates, starts, uses, stops, and deletes the BrowserOS Lima VM',
|
||||
async () => {
|
||||
expect(existsSync(templatePath)).toBe(true)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
readinessTimeoutMs: 5 * 60 * 1000,
|
||||
readinessPollMs: 1000,
|
||||
})
|
||||
const cli = new ContainerCli({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
vmName: VM_NAME,
|
||||
})
|
||||
|
||||
await runtime.ensureReady()
|
||||
expect((await stat(getContainerdSocketPath(root))).isSocket()).toBe(true)
|
||||
const nerdctlInfoOutput: string[] = []
|
||||
const nerdctlInfo = await cli.runCommand(['info'], (line) =>
|
||||
nerdctlInfoOutput.push(line),
|
||||
)
|
||||
if (nerdctlInfo.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`nerdctl info failed with exit ${nerdctlInfo.exitCode}:\n${nerdctlInfoOutput.join('\n')}`,
|
||||
)
|
||||
}
|
||||
|
||||
await cli.pullImage('docker.io/library/hello-world:latest')
|
||||
|
||||
const secondStart = Date.now()
|
||||
await runtime.ensureReady()
|
||||
expect(Date.now() - secondStart).toBeLessThan(10_000)
|
||||
|
||||
await runtime.stopVm()
|
||||
const vm = (await new LimaCli({ limactlPath, limaHome }).list()).find(
|
||||
(entry) => entry.name === VM_NAME,
|
||||
)
|
||||
expect(vm?.status).toBe('Stopped')
|
||||
},
|
||||
LIVE_VM_SMOKE_TIMEOUT_MS,
|
||||
)
|
||||
})
|
||||
@@ -1,201 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { ContainerCli } from '../../../src/lib/container/container-cli'
|
||||
import { ContainerCliError } from '../../../src/lib/vm/errors'
|
||||
import { fakeSsh } from '../../__helpers__/fake-ssh'
|
||||
|
||||
describe('ContainerCli', () => {
|
||||
let tempDir: string
|
||||
let logPath: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp('/tmp/container-cli-')
|
||||
logPath = join(tempDir, 'ssh.log')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('checks image existence with nerdctl image inspect', async () => {
|
||||
const sshPath = await fakeSsh({}, logPath)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await expect(cli.imageExists('openclaw:v1')).resolves.toBe(true)
|
||||
|
||||
const sshConfig = sshConfigPath(tempDir)
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
`${sshPrefix(sshConfig)} 'nerdctl' 'image' 'inspect' 'openclaw:v1'`,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false when image inspect exits non-zero', async () => {
|
||||
const sshPath = await fakeSsh({ stderr: 'missing', exit: 1 }, logPath)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await expect(cli.imageExists('openclaw:v1')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('pulls images with progress and throws typed command errors', async () => {
|
||||
const sshPath = await fakeSsh(
|
||||
{ stdout: 'pulling\n', stderr: 'denied', exit: 2 },
|
||||
logPath,
|
||||
)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
const lines: string[] = []
|
||||
|
||||
const error = await cli
|
||||
.pullImage('openclaw:v1', (line) => lines.push(line))
|
||||
.catch((err) => err)
|
||||
|
||||
expect(error).toBeInstanceOf(ContainerCliError)
|
||||
expect(error.exitCode).toBe(2)
|
||||
expect(error.stderr).toBe('denied')
|
||||
expect(lines).toContain('pulling')
|
||||
expect(lines).toContain('denied')
|
||||
})
|
||||
|
||||
it('loads images from guest tarballs and returns loaded refs', async () => {
|
||||
const sshPath = await fakeSsh(
|
||||
{ stdout: 'Loaded image(s): openclaw:v1\n' },
|
||||
logPath,
|
||||
)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await expect(
|
||||
cli.loadImage('/mnt/browseros/cache/images/openclaw.tar.gz'),
|
||||
).resolves.toEqual(['openclaw:v1'])
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'load' '-i' '/mnt/browseros/cache/images/openclaw.tar.gz'`,
|
||||
)
|
||||
})
|
||||
|
||||
it('creates containers from typed specs', async () => {
|
||||
const sshPath = await fakeSsh({}, logPath)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await cli.createContainer({
|
||||
name: 'gateway',
|
||||
image: 'openclaw:v1',
|
||||
restart: 'unless-stopped',
|
||||
ports: [{ hostIp: '127.0.0.1', hostPort: 18789, containerPort: 18789 }],
|
||||
envFile: '/mnt/browseros/vm/openclaw/.env',
|
||||
env: { HOME: '/home/node', NODE_ENV: 'production' },
|
||||
mounts: [
|
||||
{
|
||||
source: '/mnt/browseros/vm/openclaw',
|
||||
target: '/home/node',
|
||||
readonly: true,
|
||||
},
|
||||
],
|
||||
addHosts: ['host.containers.internal:192.168.5.2'],
|
||||
health: {
|
||||
cmd: 'curl -sf http://127.0.0.1:18789/healthz',
|
||||
interval: '30s',
|
||||
timeout: '10s',
|
||||
retries: 3,
|
||||
},
|
||||
command: ['node', 'dist/index.js', 'gateway'],
|
||||
})
|
||||
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
[
|
||||
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'create'`,
|
||||
"'--name' 'gateway'",
|
||||
"'--restart' 'unless-stopped'",
|
||||
"'-p' '127.0.0.1:18789:18789'",
|
||||
"'--env-file' '/mnt/browseros/vm/openclaw/.env'",
|
||||
"'-e' 'HOME=/home/node'",
|
||||
"'-e' 'NODE_ENV=production'",
|
||||
"'-v' '/mnt/browseros/vm/openclaw:/home/node:ro'",
|
||||
"'--add-host' 'host.containers.internal:192.168.5.2'",
|
||||
"'--health-cmd' 'curl -sf http://127.0.0.1:18789/healthz'",
|
||||
"'--health-interval' '30s'",
|
||||
"'--health-timeout' '10s'",
|
||||
"'--health-retries' '3'",
|
||||
"'openclaw:v1' 'node' 'dist/index.js' 'gateway'",
|
||||
].join(' '),
|
||||
)
|
||||
})
|
||||
|
||||
it('starts, stops, removes, execs, and lists containers', async () => {
|
||||
const sshPath = await fakeSsh({ stdout: 'gateway\nworker\n' }, logPath)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await cli.startContainer('gateway')
|
||||
await cli.stopContainer('gateway')
|
||||
await cli.removeContainer('gateway', { force: true })
|
||||
await expect(cli.exec('gateway', ['node', '--version'])).resolves.toBe(0)
|
||||
await expect(cli.ps({ namesOnly: true })).resolves.toEqual([
|
||||
'gateway',
|
||||
'worker',
|
||||
])
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
expect(log).toContain("lima-browseros-vm 'nerdctl' 'start' 'gateway'")
|
||||
expect(log).toContain("lima-browseros-vm 'nerdctl' 'stop' 'gateway'")
|
||||
expect(log).toContain("lima-browseros-vm 'nerdctl' 'rm' '-f' 'gateway'")
|
||||
expect(log).toContain(
|
||||
"lima-browseros-vm 'nerdctl' 'exec' 'gateway' 'node' '--version'",
|
||||
)
|
||||
expect(log).toContain(
|
||||
"lima-browseros-vm 'nerdctl' 'ps' '--format' '{{.Names}}'",
|
||||
)
|
||||
})
|
||||
|
||||
it('tolerates removal when the container is already absent', async () => {
|
||||
const sshPath = await fakeSsh(
|
||||
{ stderr: 'no such container', exit: 1 },
|
||||
logPath,
|
||||
)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
|
||||
await expect(cli.removeContainer('gateway', { force: true })).resolves.toBe(
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it('tails logs and returns a stop handle', async () => {
|
||||
const sshPath = await fakeSsh({ stdout: 'line\n' }, logPath)
|
||||
const cli = await createCli(sshPath, tempDir)
|
||||
const lines: string[] = []
|
||||
|
||||
const stop = cli.tailLogs('gateway', (line) => lines.push(line))
|
||||
await Bun.sleep(20)
|
||||
stop()
|
||||
|
||||
expect(lines).toEqual(['line'])
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
`${sshPrefix(sshConfigPath(tempDir))} 'nerdctl' 'logs' '-f' '-n' '0' 'gateway'`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
async function createCli(
|
||||
sshPath: string,
|
||||
tempDir: string,
|
||||
): Promise<ContainerCli> {
|
||||
const configPath = sshConfigPath(tempDir)
|
||||
await mkdir(join(tempDir, 'lima', 'browseros-vm'), { recursive: true })
|
||||
await writeFile(configPath, '')
|
||||
return new ContainerCli({
|
||||
limactlPath: 'unused',
|
||||
limaHome: join(tempDir, 'lima'),
|
||||
sshPath,
|
||||
vmName: 'browseros-vm',
|
||||
})
|
||||
}
|
||||
|
||||
function sshConfigPath(tempDir: string): string {
|
||||
return join(tempDir, 'lima', 'browseros-vm', 'ssh.config')
|
||||
}
|
||||
|
||||
function sshPrefix(configPath: string): string {
|
||||
return `ARGS:-F ${configPath} lima-browseros-vm`
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
|
||||
import type { ContainerCli } from '../../../src/lib/container/container-cli'
|
||||
import { ImageLoader } from '../../../src/lib/container/image-loader'
|
||||
import { ContainerCliError, ImageLoadError } from '../../../src/lib/vm/errors'
|
||||
import type { VmManifest } from '../../../src/lib/vm/manifest'
|
||||
import * as paths from '../../../src/lib/vm/paths'
|
||||
|
||||
const manifest: VmManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents: {
|
||||
openclaw: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'agent-arm',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
x64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
sha256: 'agent-x64',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('ImageLoader', () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
it('returns without loading when the image already exists', async () => {
|
||||
const cli = new FakeContainerCli([true])
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
|
||||
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
|
||||
expect(cli.loadCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('loads a missing image from the guest cache and verifies it exists', async () => {
|
||||
const cli = new FakeContainerCli([false, true])
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
|
||||
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
|
||||
expect(cli.loadCalls).toEqual([
|
||||
'/mnt/browseros/cache/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
])
|
||||
expect(cli.existsCalls).toEqual([
|
||||
'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves image tarballs against the configured BrowserOS root', async () => {
|
||||
const cli = new FakeContainerCli([false, true])
|
||||
const browserosRoot = '/tmp/browseros-custom-root'
|
||||
const loader = new ImageLoader(
|
||||
cli as never,
|
||||
manifest,
|
||||
'arm64',
|
||||
browserosRoot,
|
||||
)
|
||||
const getImageCacheDir = spyOn(paths, 'getImageCacheDir')
|
||||
const hostPathToGuest = spyOn(paths, 'hostPathToGuest')
|
||||
|
||||
await loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
|
||||
expect(getImageCacheDir).toHaveBeenCalledWith(browserosRoot)
|
||||
expect(hostPathToGuest).toHaveBeenCalledWith(
|
||||
'/tmp/browseros-custom-root/cache/vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
browserosRoot,
|
||||
)
|
||||
})
|
||||
|
||||
it('throws ImageLoadError when a loaded image is still absent', async () => {
|
||||
const cli = new FakeContainerCli([false, false])
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
|
||||
await expect(
|
||||
loader.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12'),
|
||||
).rejects.toThrow(ImageLoadError)
|
||||
})
|
||||
|
||||
it('throws ImageLoadError for unknown refs without loading', async () => {
|
||||
const cli = new FakeContainerCli([false])
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
|
||||
await expect(loader.ensureImageLoaded('missing:v1')).rejects.toThrow(
|
||||
ImageLoadError,
|
||||
)
|
||||
expect(cli.loadCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('wraps ContainerCliError load failures as ImageLoadError', async () => {
|
||||
const cli = new FakeContainerCli([false])
|
||||
cli.loadError = new ContainerCliError('nerdctl load', 125, 'bad archive')
|
||||
const loader = new ImageLoader(cli as never, manifest, 'arm64')
|
||||
|
||||
const error = await loader
|
||||
.ensureImageLoaded('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
.catch((err) => err)
|
||||
|
||||
expect(error).toBeInstanceOf(ImageLoadError)
|
||||
expect(error.cause).toBe(cli.loadError)
|
||||
})
|
||||
})
|
||||
|
||||
class FakeContainerCli
|
||||
implements Pick<ContainerCli, 'imageExists' | 'loadImage'>
|
||||
{
|
||||
existsCalls: string[] = []
|
||||
loadCalls: string[] = []
|
||||
loadError: Error | null = null
|
||||
|
||||
constructor(private readonly existsResponses: boolean[]) {}
|
||||
|
||||
async imageExists(ref: string): Promise<boolean> {
|
||||
this.existsCalls.push(ref)
|
||||
return this.existsResponses.shift() ?? false
|
||||
}
|
||||
|
||||
async loadImage(path: string): Promise<string[]> {
|
||||
this.loadCalls.push(path)
|
||||
if (this.loadError) throw this.loadError
|
||||
return ['loaded']
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
ContainerCliError,
|
||||
ImageLoadError,
|
||||
LimaCommandError,
|
||||
ManifestMissingError,
|
||||
VmError,
|
||||
VmNotReadyError,
|
||||
VmStateCorruptedError,
|
||||
} from '../../../src/lib/vm/errors'
|
||||
import { VM_TELEMETRY_EVENTS } from '../../../src/lib/vm/telemetry'
|
||||
|
||||
describe('VM errors', () => {
|
||||
it('keeps all VM domain errors under VmError', () => {
|
||||
const errors = [
|
||||
new VmError('base'),
|
||||
new VmNotReadyError('not ready'),
|
||||
new VmStateCorruptedError('corrupt'),
|
||||
new LimaCommandError('limactl start', 7, 'bad lima'),
|
||||
new ContainerCliError('nerdctl pull', 8, 'bad nerdctl'),
|
||||
new ImageLoadError('openclaw:v1', 'bad image'),
|
||||
new ManifestMissingError('/tmp/manifest.json'),
|
||||
]
|
||||
|
||||
for (const error of errors) {
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error).toBeInstanceOf(VmError)
|
||||
}
|
||||
})
|
||||
|
||||
it('carries command failure details', () => {
|
||||
const lima = new LimaCommandError('limactl start', 12, 'stderr text')
|
||||
const container = new ContainerCliError(
|
||||
'nerdctl pull',
|
||||
13,
|
||||
'nerdctl stderr',
|
||||
)
|
||||
|
||||
expect(lima.exitCode).toBe(12)
|
||||
expect(lima.stderr).toBe('stderr text')
|
||||
expect(container.exitCode).toBe(13)
|
||||
expect(container.stderr).toBe('nerdctl stderr')
|
||||
})
|
||||
|
||||
it('exports VM telemetry event names', () => {
|
||||
expect(VM_TELEMETRY_EVENTS.ensureReadyStart).toBe('vm.ensure_ready.start')
|
||||
expect(VM_TELEMETRY_EVENTS.downgradeDetected).toBe('vm.downgrade.detected')
|
||||
expect(VM_TELEMETRY_EVENTS.nerdctlWaitTimeout).toBe(
|
||||
'vm.nerdctl_wait.timeout',
|
||||
)
|
||||
expect(VM_TELEMETRY_EVENTS.migrationOpenClawMoved).toBe(
|
||||
'vm.migration.openclaw_moved',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,211 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
mock,
|
||||
spyOn,
|
||||
} from 'bun:test'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { logger } from '../../../src/lib/logger'
|
||||
import { LimaCommandError, VmNotReadyError } from '../../../src/lib/vm/errors'
|
||||
import { LimaCli } from '../../../src/lib/vm/lima-cli'
|
||||
import { VM_TELEMETRY_EVENTS } from '../../../src/lib/vm/telemetry'
|
||||
import { fakeLimactl } from '../../__helpers__/fake-limactl'
|
||||
import { fakeSsh } from '../../__helpers__/fake-ssh'
|
||||
|
||||
describe('LimaCli', () => {
|
||||
let tempDir: string
|
||||
let logPath: string
|
||||
let limaHome: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'lima-cli-test-'))
|
||||
logPath = join(tempDir, 'calls.log')
|
||||
limaHome = join(tempDir, 'lima-home')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('parses limactl list JSON output', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
name: 'browseros-vm',
|
||||
status: 'Running',
|
||||
dir: '/lima/browseros-vm',
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const cli = new LimaCli({ limactlPath, limaHome })
|
||||
|
||||
await expect(cli.list()).resolves.toEqual([
|
||||
{ name: 'browseros-vm', status: 'Running', dir: '/lima/browseros-vm' },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty VM list when limactl prints no output', async () => {
|
||||
const limactlPath = await fakeLimactl({ list: { stdout: '' } }, logPath)
|
||||
const cli = new LimaCli({ limactlPath, limaHome })
|
||||
|
||||
await expect(cli.list()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('creates VMs with LIMA_HOME and the expected argv', async () => {
|
||||
const limactlPath = await fakeLimactl({ create: {} }, logPath)
|
||||
const cli = new LimaCli({ limactlPath, limaHome })
|
||||
|
||||
await cli.create('browseros-vm', '/tmp/browseros-vm.yaml')
|
||||
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
'ARGS:create --tty=false --name=browseros-vm /tmp/browseros-vm.yaml',
|
||||
)
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
`LIMA_HOME:${limaHome}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('starts VMs with tty disabled', async () => {
|
||||
const limactlPath = await fakeLimactl({ start: {} }, logPath)
|
||||
const cli = new LimaCli({ limactlPath, limaHome })
|
||||
|
||||
await cli.start('browseros-vm')
|
||||
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
'ARGS:start --tty=false browseros-vm',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws LimaCommandError with stderr on non-zero exit', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ start: { stderr: 'cannot start', exit: 2 } },
|
||||
logPath,
|
||||
)
|
||||
const cli = new LimaCli({ limactlPath, limaHome })
|
||||
|
||||
const error = await cli.start('browseros-vm').catch((err) => err)
|
||||
|
||||
expect(error).toBeInstanceOf(LimaCommandError)
|
||||
expect(error.exitCode).toBe(2)
|
||||
expect(error.stderr).toBe('cannot start')
|
||||
})
|
||||
|
||||
it('does not log limactl stderr chunks by default', async () => {
|
||||
const debug = spyOn(logger, 'debug').mockImplementation(() => {})
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ start: { stderr: 'boot noise\n' } },
|
||||
logPath,
|
||||
)
|
||||
const cli = new LimaCli({ limactlPath, limaHome })
|
||||
|
||||
await cli.start('browseros-vm')
|
||||
|
||||
expect(
|
||||
debug.mock.calls.some(
|
||||
([message]) => message === VM_TELEMETRY_EVENTS.limaStderrChunk,
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('stops and deletes VMs', async () => {
|
||||
const limactlPath = await fakeLimactl({ stop: {}, delete: {} }, logPath)
|
||||
const cli = new LimaCli({ limactlPath, limaHome })
|
||||
|
||||
await cli.stop('browseros-vm')
|
||||
await cli.delete('browseros-vm')
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
expect(log).toContain('ARGS:stop browseros-vm')
|
||||
expect(log).toContain('ARGS:delete --force browseros-vm')
|
||||
})
|
||||
|
||||
it('runs shell commands and streams stdout and stderr', async () => {
|
||||
const sshPath = await fakeSsh({ stdout: 'out\n', stderr: 'err\n' }, logPath)
|
||||
const sshConfig = join(limaHome, 'browseros-vm', 'ssh.config')
|
||||
await mkdir(join(limaHome, 'browseros-vm'), { recursive: true })
|
||||
await writeFile(sshConfig, '')
|
||||
const cli = new LimaCli({ limactlPath: 'unused', limaHome, sshPath })
|
||||
const lines: string[] = []
|
||||
|
||||
await expect(
|
||||
cli.shell('browseros-vm', ['nerdctl', 'ps'], {
|
||||
onStdout: (line) => lines.push(`stdout:${line}`),
|
||||
onStderr: (line) => lines.push(`stderr:${line}`),
|
||||
}),
|
||||
).resolves.toBe(0)
|
||||
|
||||
expect(lines).toContain('stdout:out')
|
||||
expect(lines).toContain('stderr:err')
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
`ARGS:-F ${sshConfig} lima-browseros-vm 'nerdctl' 'ps'`,
|
||||
)
|
||||
})
|
||||
|
||||
it('shell-quotes remote commands to preserve argument boundaries', async () => {
|
||||
const sshPath = await fakeSsh({}, logPath)
|
||||
const sshConfig = join(limaHome, 'browseros-vm', 'ssh.config')
|
||||
await mkdir(join(limaHome, 'browseros-vm'), { recursive: true })
|
||||
await writeFile(sshConfig, '')
|
||||
const cli = new LimaCli({ limactlPath: 'unused', limaHome, sshPath })
|
||||
|
||||
await expect(
|
||||
cli.shell('browseros-vm', ['sh', '-lc', "echo 'boundary ok'"]),
|
||||
).resolves.toBe(0)
|
||||
|
||||
await expect(readFile(logPath, 'utf8')).resolves.toContain(
|
||||
`ARGS:-F ${sshConfig} lima-browseros-vm 'sh' '-lc' 'echo '\\''boundary ok'\\'''`,
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores shell stderr when no stderr stream handler is provided', async () => {
|
||||
const sshConfig = join(limaHome, 'browseros-vm', 'ssh.config')
|
||||
await mkdir(join(limaHome, 'browseros-vm'), { recursive: true })
|
||||
await writeFile(sshConfig, '')
|
||||
const spawn = spyOn(Bun, 'spawn')
|
||||
spawn.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
exited: Promise.resolve(0),
|
||||
}) as never,
|
||||
)
|
||||
const cli = new LimaCli({ limactlPath: 'limactl', limaHome })
|
||||
|
||||
await expect(
|
||||
cli.shell('browseros-vm', ['true'], {
|
||||
onStdout: () => {},
|
||||
}),
|
||||
).resolves.toBe(0)
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
['ssh', '-F', sshConfig, 'lima-browseros-vm', "'true'"],
|
||||
expect.objectContaining({
|
||||
stdout: 'pipe',
|
||||
stderr: 'ignore',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('throws VmNotReadyError when ssh.config is missing', async () => {
|
||||
const cli = new LimaCli({ limactlPath: 'limactl', limaHome })
|
||||
const error = await cli.shell('browseros-vm', ['true']).catch((err) => err)
|
||||
expect(error).toBeInstanceOf(VmNotReadyError)
|
||||
})
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { renderLimaTemplate } from '../../../src/lib/vm/lima-config'
|
||||
|
||||
describe('renderLimaTemplate', () => {
|
||||
it('injects BrowserOS host mounts into the bundled Lima template', () => {
|
||||
const yaml = renderLimaTemplate(
|
||||
'minimumLimaVersion: 2.0.0\nmounts: []\nprobes: []\n',
|
||||
{
|
||||
vmStateDir: '/Users/me/.browseros/vm',
|
||||
imageCacheDir: '/Users/me/.browseros/cache/vm/images',
|
||||
},
|
||||
)
|
||||
|
||||
expect(yaml).toContain('mountPoint: "/mnt/browseros/vm"')
|
||||
expect(yaml).toContain('location: "/Users/me/.browseros/vm"')
|
||||
expect(yaml).toContain('mountPoint: "/mnt/browseros/cache/images"')
|
||||
expect(yaml).toContain('location: "/Users/me/.browseros/cache/vm/images"')
|
||||
expect(yaml).toContain('probes: []')
|
||||
})
|
||||
|
||||
it('fails loudly if the template no longer has the expected mount marker', () => {
|
||||
expect(() =>
|
||||
renderLimaTemplate('minimumLimaVersion: 2.0.0\n', {
|
||||
vmStateDir: '/state',
|
||||
imageCacheDir: '/images',
|
||||
}),
|
||||
).toThrow('mounts: [] marker')
|
||||
})
|
||||
})
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { ManifestMissingError } from '../../../src/lib/vm/errors'
|
||||
import {
|
||||
agentForArch,
|
||||
compareVersions,
|
||||
readCachedManifest,
|
||||
readInstalledManifest,
|
||||
type VmManifest,
|
||||
writeInstalledManifest,
|
||||
} from '../../../src/lib/vm/manifest'
|
||||
|
||||
const manifest: VmManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents: {
|
||||
openclaw: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'c',
|
||||
sizeBytes: 3,
|
||||
},
|
||||
x64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
sha256: 'd',
|
||||
sizeBytes: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('VM manifest helpers', () => {
|
||||
let root: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'browseros-vm-manifest-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('reads the cached manifest', async () => {
|
||||
const manifestPath = join(root, 'cache', 'vm', 'manifest.json')
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await Bun.write(manifestPath, `${JSON.stringify(manifest)}\n`)
|
||||
|
||||
await expect(readCachedManifest(root)).resolves.toEqual(manifest)
|
||||
})
|
||||
|
||||
it('throws ManifestMissingError when cached manifest is absent', async () => {
|
||||
await expect(readCachedManifest(root)).rejects.toThrow(ManifestMissingError)
|
||||
})
|
||||
|
||||
it('returns null for a missing installed manifest', async () => {
|
||||
await expect(readInstalledManifest(root)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('reads the installed manifest', async () => {
|
||||
const manifestPath = join(root, 'vm', 'manifest.json')
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await Bun.write(manifestPath, `${JSON.stringify(manifest)}\n`)
|
||||
|
||||
await expect(readInstalledManifest(root)).resolves.toEqual(manifest)
|
||||
})
|
||||
|
||||
it('throws on malformed installed manifest JSON', async () => {
|
||||
const manifestPath = join(root, 'vm', 'manifest.json')
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await Bun.write(manifestPath, '{not-json')
|
||||
|
||||
await expect(readInstalledManifest(root)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('writes the installed manifest atomically', async () => {
|
||||
await writeInstalledManifest(manifest, root)
|
||||
|
||||
const raw = await readFile(join(root, 'vm', 'manifest.json'), 'utf8')
|
||||
expect(JSON.parse(raw)).toEqual(manifest)
|
||||
})
|
||||
|
||||
it('compares installed and cached versions', () => {
|
||||
const older = { ...manifest, updatedAt: '2026-04-21T00:00:00.000Z' }
|
||||
const newer = { ...manifest, updatedAt: '2026-04-23T00:00:00.000Z' }
|
||||
|
||||
expect(compareVersions(null, manifest)).toBe('fresh')
|
||||
expect(compareVersions(manifest, manifest)).toBe('same')
|
||||
expect(compareVersions(older, manifest)).toBe('upgrade')
|
||||
expect(compareVersions(newer, manifest)).toBe('downgrade')
|
||||
})
|
||||
|
||||
it('compares ISO timestamp versions with time-of-day precision', () => {
|
||||
const morning = {
|
||||
...manifest,
|
||||
updatedAt: '2026-04-22T10:00:00.000Z',
|
||||
}
|
||||
const afternoon = {
|
||||
...manifest,
|
||||
updatedAt: '2026-04-22T15:00:00.000Z',
|
||||
}
|
||||
|
||||
expect(compareVersions(morning, afternoon)).toBe('upgrade')
|
||||
expect(compareVersions(afternoon, morning)).toBe('downgrade')
|
||||
})
|
||||
|
||||
it('returns the requested agent tarball for an arch', () => {
|
||||
expect(agentForArch(manifest, 'openclaw', 'arm64')).toEqual({
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarball: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'c',
|
||||
sizeBytes: 3,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when an agent or arch is absent', () => {
|
||||
expect(() => agentForArch(manifest, 'missing', 'arm64')).toThrow(
|
||||
'missing agent',
|
||||
)
|
||||
expect(() =>
|
||||
agentForArch(manifest, 'openclaw', 'x64' as never),
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,230 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import {
|
||||
getLegacyOpenClawDir,
|
||||
getOpenClawDir,
|
||||
} from '../../../src/lib/browseros-dir'
|
||||
import {
|
||||
detectArch,
|
||||
getCachedManifestPath,
|
||||
getContainerdSocketPath,
|
||||
getImageCacheDir,
|
||||
getInstalledManifestPath,
|
||||
getLimaHomeDir,
|
||||
getVmCacheDir,
|
||||
getVmStateDir,
|
||||
hostPathToGuest,
|
||||
resolveBundledLimactl,
|
||||
resolveBundledLimaTemplate,
|
||||
} from '../../../src/lib/vm/paths'
|
||||
|
||||
describe('VM paths', () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV
|
||||
const originalPath = process.env.PATH
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV
|
||||
} else {
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
}
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH
|
||||
} else {
|
||||
process.env.PATH = originalPath
|
||||
}
|
||||
})
|
||||
|
||||
it('uses production VM directories below .browseros', () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
expect(getLimaHomeDir()).toBe(join(homedir(), '.browseros', 'lima'))
|
||||
expect(getVmStateDir()).toBe(join(homedir(), '.browseros', 'vm'))
|
||||
expect(getOpenClawDir()).toBe(
|
||||
join(homedir(), '.browseros', 'vm', 'openclaw'),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses development VM directories below .browseros-dev', () => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
|
||||
expect(getLimaHomeDir()).toBe(join(homedir(), '.browseros-dev', 'lima'))
|
||||
expect(getVmStateDir()).toBe(join(homedir(), '.browseros-dev', 'vm'))
|
||||
expect(getOpenClawDir()).toBe(
|
||||
join(homedir(), '.browseros-dev', 'vm', 'openclaw'),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the legacy OpenClaw directory addressable for migration', () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
expect(getLegacyOpenClawDir()).toBe(
|
||||
join(homedir(), PATHS.BROWSEROS_DIR_NAME, PATHS.OPENCLAW_DIR_NAME),
|
||||
)
|
||||
})
|
||||
|
||||
it('builds cached and installed manifest paths', () => {
|
||||
const root = '/Users/foo/.browseros'
|
||||
|
||||
expect(getVmCacheDir(root)).toBe('/Users/foo/.browseros/cache/vm')
|
||||
expect(getImageCacheDir(root)).toBe('/Users/foo/.browseros/cache/vm/images')
|
||||
expect(getCachedManifestPath(root)).toBe(
|
||||
'/Users/foo/.browseros/cache/vm/manifest.json',
|
||||
)
|
||||
expect(getInstalledManifestPath(root)).toBe(
|
||||
'/Users/foo/.browseros/vm/manifest.json',
|
||||
)
|
||||
expect(getContainerdSocketPath(root)).toBe(
|
||||
'/Users/foo/.browseros/lima/browseros-vm/sock/containerd.sock',
|
||||
)
|
||||
})
|
||||
|
||||
it('translates mounted host paths into guest paths', () => {
|
||||
const root = '/Users/foo/.browseros'
|
||||
|
||||
expect(hostPathToGuest('/Users/foo/.browseros/vm/openclaw/x', root)).toBe(
|
||||
'/mnt/browseros/vm/openclaw/x',
|
||||
)
|
||||
expect(
|
||||
hostPathToGuest('/Users/foo/.browseros/cache/vm/images/a.tar.gz', root),
|
||||
).toBe('/mnt/browseros/cache/images/a.tar.gz')
|
||||
})
|
||||
|
||||
it('rejects unmapped host paths', () => {
|
||||
expect(() =>
|
||||
hostPathToGuest('/tmp/other', '/Users/foo/.browseros'),
|
||||
).toThrow('not under any known guest mount')
|
||||
})
|
||||
|
||||
it('detects supported host architectures', () => {
|
||||
expect(detectArch('arm64')).toBe('arm64')
|
||||
expect(detectArch('x64')).toBe('x64')
|
||||
})
|
||||
|
||||
it('rejects unsupported host architectures', () => {
|
||||
expect(() => detectArch('ppc64' as NodeJS.Architecture)).toThrow(
|
||||
'unsupported host arch',
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves the bundled limactl executable', async () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'limactl-resources-'))
|
||||
const limactlPath = join(
|
||||
resourcesDir,
|
||||
'bin',
|
||||
'third_party',
|
||||
'lima',
|
||||
'limactl',
|
||||
)
|
||||
await mkdir(dirname(limactlPath), { recursive: true })
|
||||
await writeFile(limactlPath, '#!/bin/sh\n')
|
||||
|
||||
try {
|
||||
expect(resolveBundledLimactl(resourcesDir)).toBe(limactlPath)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('uses PATH limactl in development mode', async () => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
const binDir = await createFakeLimactlPath()
|
||||
|
||||
try {
|
||||
expect(resolveBundledLimactl('/tmp/missing-dev-resources')).toBe(
|
||||
join(binDir, 'limactl'),
|
||||
)
|
||||
} finally {
|
||||
await rm(binDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('uses PATH limactl in test mode', async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
const binDir = await createFakeLimactlPath()
|
||||
|
||||
try {
|
||||
expect(resolveBundledLimactl('/tmp/missing-test-resources')).toBe(
|
||||
join(binDir, 'limactl'),
|
||||
)
|
||||
} finally {
|
||||
await rm(binDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('throws with a brew install hint when host limactl is missing', async () => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
const binDir = await mkdtemp(join(tmpdir(), 'missing-host-limactl-'))
|
||||
process.env.PATH = binDir
|
||||
|
||||
try {
|
||||
expect(() => resolveBundledLimactl('/tmp/missing-dev-resources')).toThrow(
|
||||
'brew install lima',
|
||||
)
|
||||
} finally {
|
||||
await rm(binDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('throws with a build-tools hint when bundled limactl is missing', () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
expect(() => resolveBundledLimactl('/tmp/missing-resources')).toThrow(
|
||||
'build-tools README',
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves the bundled Lima template', async () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
const resourcesDir = await mkdtemp(join(tmpdir(), 'lima-template-'))
|
||||
const templatePath = join(resourcesDir, 'vm', 'browseros-vm.yaml')
|
||||
await mkdir(dirname(templatePath), { recursive: true })
|
||||
await writeFile(templatePath, 'mounts: []\n')
|
||||
|
||||
try {
|
||||
expect(resolveBundledLimaTemplate(resourcesDir)).toBe(templatePath)
|
||||
} finally {
|
||||
await rm(resourcesDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves the source Lima template from a package workspace in test mode', async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
const workspaceDir = await mkdtemp(join(tmpdir(), 'lima-source-template-'))
|
||||
const resourcesDir = join(workspaceDir, 'packages', 'browseros-agent')
|
||||
const templatePath = join(
|
||||
workspaceDir,
|
||||
'packages',
|
||||
'build-tools',
|
||||
'template',
|
||||
'browseros-vm.yaml',
|
||||
)
|
||||
await mkdir(resourcesDir, { recursive: true })
|
||||
await mkdir(dirname(templatePath), { recursive: true })
|
||||
await writeFile(templatePath, 'mounts: []\n')
|
||||
|
||||
try {
|
||||
expect(resolveBundledLimaTemplate(resourcesDir)).toBe(templatePath)
|
||||
} finally {
|
||||
await rm(workspaceDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function createFakeLimactlPath(): Promise<string> {
|
||||
const binDir = await mkdtemp(join(tmpdir(), 'host-limactl-'))
|
||||
const limactlPath = join(binDir, 'limactl')
|
||||
await writeFile(limactlPath, '#!/bin/sh\n')
|
||||
await chmod(limactlPath, 0o755)
|
||||
process.env.PATH = binDir
|
||||
return binDir
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { logger } from '../../../src/lib/logger'
|
||||
import { VmNotReadyError } from '../../../src/lib/vm/errors'
|
||||
import type { VmManifest } from '../../../src/lib/vm/manifest'
|
||||
import {
|
||||
getCachedManifestPath,
|
||||
getInstalledManifestPath,
|
||||
VM_NAME,
|
||||
} from '../../../src/lib/vm/paths'
|
||||
import { VM_TELEMETRY_EVENTS } from '../../../src/lib/vm/telemetry'
|
||||
import { VmRuntime } from '../../../src/lib/vm/vm-runtime'
|
||||
import { fakeLimactl } from '../../__helpers__/fake-limactl'
|
||||
import { fakeSsh } from '../../__helpers__/fake-ssh'
|
||||
|
||||
const manifest: VmManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents: {
|
||||
openclaw: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'agent-arm',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
x64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
sha256: 'agent-x64',
|
||||
sizeBytes: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('VmRuntime', () => {
|
||||
let root: string
|
||||
let limaHome: string
|
||||
let logPath: string
|
||||
let templatePath: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp('/tmp/vmrt-')
|
||||
limaHome = join(root, 'lima')
|
||||
logPath = join(root, 'limactl.log')
|
||||
templatePath = join(root, 'browseros-vm.yaml')
|
||||
await writeCachedManifest(root)
|
||||
await writeFile(templatePath, 'minimumLimaVersion: 2.0.0\nmounts: []\n')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('provisions a fresh VM, waits for rootless nerdctl, and installs the manifest', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ list: { stdout: '' }, create: {}, start: {} },
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareReadySsh(limaHome, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
expect(log).toContain(`ARGS:create --tty=false --name=${VM_NAME}`)
|
||||
expect(log).toContain(`ARGS:start --tty=false ${VM_NAME}`)
|
||||
expect(log).toContain(`lima-${VM_NAME} 'nerdctl' 'info'`)
|
||||
await expect(
|
||||
readFile(getInstalledManifestPath(root), 'utf8'),
|
||||
).resolves.toContain(manifest.updatedAt)
|
||||
await expect(
|
||||
readFile(join(limaHome, `${VM_NAME}.yaml`), 'utf8'),
|
||||
).resolves.toContain('mountPoint: "/mnt/browseros/vm"')
|
||||
})
|
||||
|
||||
it('returns fast when the VM is already running and manifests match', async () => {
|
||||
await writeInstalledManifest(root)
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
stdout: JSON.stringify([
|
||||
{ name: VM_NAME, status: 'Running', dir: limaHome },
|
||||
]),
|
||||
},
|
||||
create: { stderr: 'should not create', exit: 9 },
|
||||
start: { stderr: 'should not start', exit: 9 },
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareReadySsh(limaHome, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
expect(log).toContain('ARGS:list --format json')
|
||||
expect(log).not.toContain('ARGS:create')
|
||||
expect(log).not.toContain('ARGS:start')
|
||||
})
|
||||
|
||||
it('starts an existing stopped VM without recreating it', async () => {
|
||||
await writeInstalledManifest(root)
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
stdout: JSON.stringify([
|
||||
{ name: VM_NAME, status: 'Stopped', dir: limaHome },
|
||||
]),
|
||||
},
|
||||
start: {},
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareReadySsh(limaHome, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
expect(log).toContain(`ARGS:start --tty=false ${VM_NAME}`)
|
||||
expect(log).not.toContain('ARGS:create')
|
||||
})
|
||||
|
||||
it('recreates an existing VM that does not have the containerd runtime marker', async () => {
|
||||
await writeInstalledManifest(root)
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
stdout: JSON.stringify([
|
||||
{ name: VM_NAME, status: 'Running', dir: limaHome },
|
||||
]),
|
||||
},
|
||||
stop: {},
|
||||
delete: {},
|
||||
create: {},
|
||||
start: {},
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await fakeRootfulThenReadySsh(root, logPath)
|
||||
await writeSshConfig(limaHome)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
expect(log).toContain(`lima-${VM_NAME} 'nerdctl' 'info'`)
|
||||
expect(log).toContain(
|
||||
`lima-${VM_NAME} 'sh' '-lc' 'cat /etc/browseros-vm-version 2>/dev/null || true'`,
|
||||
)
|
||||
expect(log).toContain(`ARGS:stop ${VM_NAME}`)
|
||||
expect(log).toContain(`ARGS:delete --force ${VM_NAME}`)
|
||||
expect(log).toContain(`ARGS:create --tty=false --name=${VM_NAME}`)
|
||||
expect(log).toContain(`ARGS:start --tty=false ${VM_NAME}`)
|
||||
})
|
||||
|
||||
it('treats stopVm as idempotent when the VM is already stopped', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ stop: { stderr: 'instance is not running', exit: 1 } },
|
||||
logPath,
|
||||
)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await expect(runtime.stopVm()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('requires a bundled Lima template for fresh VM provisioning', async () => {
|
||||
const limactlPath = await fakeLimactl({ list: { stdout: '' } }, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await expect(runtime.ensureReady()).rejects.toThrow('Lima template path')
|
||||
})
|
||||
|
||||
it('throws VmNotReadyError when rootless nerdctl never becomes ready', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ list: { stdout: '' }, create: {}, start: {} },
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareFailingSsh(limaHome, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
readinessTimeoutMs: 10,
|
||||
readinessPollMs: 1,
|
||||
})
|
||||
|
||||
await expect(runtime.ensureReady()).rejects.toThrow(VmNotReadyError)
|
||||
})
|
||||
|
||||
it('exposes a reset stub with a follow-up-plan message', async () => {
|
||||
const limactlPath = await fakeLimactl({}, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await expect(runtime.reset('bad disk')).rejects.toThrow(
|
||||
'VmRuntime.reset is not implemented yet',
|
||||
)
|
||||
})
|
||||
|
||||
it('logs upgrade mismatch and preserves the installed manifest until upgrade happens', async () => {
|
||||
await writeInstalledManifest(root, '2026-04-21T00:00:00.000Z')
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
stdout: JSON.stringify([
|
||||
{ name: VM_NAME, status: 'Running', dir: limaHome },
|
||||
]),
|
||||
},
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareReadySsh(limaHome, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
const originalWarn = logger.warn
|
||||
const warnings: Array<{
|
||||
message: string
|
||||
meta?: Record<string, unknown>
|
||||
}> = []
|
||||
logger.warn = (message, meta) => warnings.push({ message, meta })
|
||||
|
||||
try {
|
||||
await runtime.ensureReady()
|
||||
} finally {
|
||||
logger.warn = originalWarn
|
||||
}
|
||||
|
||||
expect(warnings).toContainEqual({
|
||||
message: VM_TELEMETRY_EVENTS.upgradeDetected,
|
||||
meta: {
|
||||
from: '2026-04-21T00:00:00.000Z',
|
||||
to: '2026-04-22T00:00:00.000Z',
|
||||
},
|
||||
})
|
||||
expect(await readInstalledUpdatedAt(root)).toBe('2026-04-21T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('logs downgrade mismatch and preserves a newer installed manifest', async () => {
|
||||
await writeInstalledManifest(root, '2026-04-23T00:00:00.000Z')
|
||||
const limactlPath = await fakeLimactl(
|
||||
{
|
||||
list: {
|
||||
stdout: JSON.stringify([
|
||||
{ name: VM_NAME, status: 'Running', dir: limaHome },
|
||||
]),
|
||||
},
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareReadySsh(limaHome, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
const originalWarn = logger.warn
|
||||
const warnings: Array<{
|
||||
message: string
|
||||
meta?: Record<string, unknown>
|
||||
}> = []
|
||||
logger.warn = (message, meta) => warnings.push({ message, meta })
|
||||
|
||||
try {
|
||||
await runtime.ensureReady()
|
||||
} finally {
|
||||
logger.warn = originalWarn
|
||||
}
|
||||
|
||||
expect(warnings).toContainEqual({
|
||||
message: VM_TELEMETRY_EVENTS.downgradeDetected,
|
||||
meta: {
|
||||
from: '2026-04-23T00:00:00.000Z',
|
||||
to: '2026-04-22T00:00:00.000Z',
|
||||
},
|
||||
})
|
||||
expect(await readInstalledUpdatedAt(root)).toBe('2026-04-23T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('does not auto-reset when rootless nerdctl readiness fails', async () => {
|
||||
const limactlPath = await fakeLimactl(
|
||||
{ list: { stdout: '' }, create: {}, start: {} },
|
||||
logPath,
|
||||
)
|
||||
const sshPath = await prepareFailingSsh(limaHome, logPath)
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
sshPath,
|
||||
templatePath,
|
||||
browserosRoot: root,
|
||||
readinessTimeoutMs: 10,
|
||||
readinessPollMs: 1,
|
||||
})
|
||||
let resetCalled = false
|
||||
runtime.reset = async () => {
|
||||
resetCalled = true
|
||||
throw new Error('reset called')
|
||||
}
|
||||
|
||||
await expect(runtime.ensureReady()).rejects.toThrow(VmNotReadyError)
|
||||
expect(resetCalled).toBe(false)
|
||||
})
|
||||
|
||||
it('delegates runCommand through ssh', async () => {
|
||||
const sshPath = await fakeSsh({}, logPath)
|
||||
const sshConfig = join(limaHome, VM_NAME, 'ssh.config')
|
||||
await mkdir(join(limaHome, VM_NAME), { recursive: true })
|
||||
await writeFile(sshConfig, '')
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath: 'unused',
|
||||
limaHome,
|
||||
sshPath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await expect(runtime.runCommand(['nerdctl', 'version'])).resolves.toBe(0)
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
expect(log).toContain(
|
||||
`ARGS:-F ${sshConfig} lima-${VM_NAME} 'nerdctl' 'version'`,
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves and caches the VM default gateway through ssh', async () => {
|
||||
const sshPath = await fakeSsh(
|
||||
{
|
||||
stdout:
|
||||
'default via 192.168.5.2 dev eth0 proto dhcp src 192.168.5.15 metric 100\n',
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
const sshConfig = join(limaHome, VM_NAME, 'ssh.config')
|
||||
await mkdir(join(limaHome, VM_NAME), { recursive: true })
|
||||
await writeFile(sshConfig, '')
|
||||
const runtime = new VmRuntime({
|
||||
limactlPath: 'unused',
|
||||
limaHome,
|
||||
sshPath,
|
||||
browserosRoot: root,
|
||||
})
|
||||
|
||||
await expect(runtime.getDefaultGateway()).resolves.toBe('192.168.5.2')
|
||||
await expect(runtime.getDefaultGateway()).resolves.toBe('192.168.5.2')
|
||||
|
||||
const log = await readFile(logPath, 'utf8')
|
||||
expect(log.match(/'ip' '-4' 'route' 'show' 'default'/g)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
async function writeCachedManifest(root: string): Promise<void> {
|
||||
const manifestPath = getCachedManifestPath(root)
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`)
|
||||
}
|
||||
|
||||
async function writeInstalledManifest(
|
||||
root: string,
|
||||
updatedAt = manifest.updatedAt,
|
||||
): Promise<void> {
|
||||
const manifestPath = getInstalledManifestPath(root)
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
await writeFile(
|
||||
manifestPath,
|
||||
`${JSON.stringify({ ...manifest, updatedAt })}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
async function readInstalledUpdatedAt(root: string): Promise<string> {
|
||||
const raw = await readFile(getInstalledManifestPath(root), 'utf8')
|
||||
return (JSON.parse(raw) as VmManifest).updatedAt
|
||||
}
|
||||
|
||||
async function prepareReadySsh(
|
||||
limaHome: string,
|
||||
logPath: string,
|
||||
): Promise<string> {
|
||||
await writeSshConfig(limaHome)
|
||||
return fakeSsh({}, logPath)
|
||||
}
|
||||
|
||||
async function prepareFailingSsh(
|
||||
limaHome: string,
|
||||
logPath: string,
|
||||
): Promise<string> {
|
||||
await writeSshConfig(limaHome)
|
||||
return fakeSsh(
|
||||
{
|
||||
stderr:
|
||||
'rootless containerd not running? stat /run/user/501/containerd-rootless: no such file or directory',
|
||||
exit: 1,
|
||||
},
|
||||
logPath,
|
||||
)
|
||||
}
|
||||
|
||||
async function writeSshConfig(limaHome: string): Promise<void> {
|
||||
await mkdir(join(limaHome, VM_NAME), { recursive: true })
|
||||
await writeFile(join(limaHome, VM_NAME, 'ssh.config'), '')
|
||||
}
|
||||
|
||||
async function fakeRootfulThenReadySsh(
|
||||
root: string,
|
||||
logPath: string,
|
||||
): Promise<string> {
|
||||
const path = join(root, 'ssh-rootful-then-ready')
|
||||
const counterPath = join(root, 'ssh-rootful-then-ready.count')
|
||||
const body = `#!/usr/bin/env bash
|
||||
set -u
|
||||
echo "ARGS:$*" >> "${logPath}"
|
||||
count="$(cat "${counterPath}" 2>/dev/null || echo 0)"
|
||||
next=$((count + 1))
|
||||
printf '%s' "$next" > "${counterPath}"
|
||||
case "$count" in
|
||||
0)
|
||||
echo "rootless containerd not running" >&2
|
||||
exit 1
|
||||
;;
|
||||
1)
|
||||
printf 'runtime:containerd\\n'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
`
|
||||
await writeFile(path, body)
|
||||
await chmod(path, 0o755)
|
||||
return path
|
||||
}
|
||||
@@ -36,6 +36,9 @@ describe('Application.start', () => {
|
||||
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')
|
||||
|
||||
@@ -88,12 +91,7 @@ describe('Application.start', () => {
|
||||
spyOn(remoteSyncModule, 'startSkillSync').mockImplementation(() => {})
|
||||
spyOn(remoteSyncModule, 'stopSkillSync').mockImplementation(() => {})
|
||||
|
||||
spyOn(openclawService, 'configureVmRuntime').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
tryAutoStart: async () => {},
|
||||
}) as never,
|
||||
)
|
||||
spyOn(podmanRuntime, 'configurePodmanRuntime').mockImplementation(() => {})
|
||||
spyOn(openclawService, 'configureOpenClawService').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { buildTestCommand, withTestEnv } from './__helpers__/run-test-group'
|
||||
|
||||
describe('withTestEnv', () => {
|
||||
it('defaults NODE_ENV to test when absent', () => {
|
||||
expect(withTestEnv({ PATH: '/usr/bin' }).NODE_ENV).toBe('test')
|
||||
})
|
||||
|
||||
it('preserves an explicit NODE_ENV', () => {
|
||||
expect(withTestEnv({ NODE_ENV: 'production' }).NODE_ENV).toBe('production')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTestCommand', () => {
|
||||
it('preloads the test env bootstrap before running targets', () => {
|
||||
expect(buildTestCommand(['./tests/api'])).toEqual([
|
||||
process.execPath,
|
||||
'--env-file=.env.development',
|
||||
'test',
|
||||
'--preload=./tests/__helpers__/test-env.ts',
|
||||
'./tests/api',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -232,17 +232,6 @@
|
||||
"zod": "^3.x",
|
||||
},
|
||||
},
|
||||
"packages/build-tools": {
|
||||
"name": "@browseros/build-tools",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.933.0",
|
||||
"@browseros/shared": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.3",
|
||||
},
|
||||
},
|
||||
"packages/cdp-protocol": {
|
||||
"name": "@browseros/cdp-protocol",
|
||||
"version": "0.0.1",
|
||||
@@ -474,8 +463,6 @@
|
||||
|
||||
"@browseros/agent": ["@browseros/agent@workspace:apps/agent"],
|
||||
|
||||
"@browseros/build-tools": ["@browseros/build-tools@workspace:packages/build-tools"],
|
||||
|
||||
"@browseros/cdp-protocol": ["@browseros/cdp-protocol@workspace:packages/cdp-protocol"],
|
||||
|
||||
"@browseros/eval": ["@browseros/eval@workspace:apps/eval"],
|
||||
@@ -4502,8 +4489,6 @@
|
||||
|
||||
"@browseros/agent/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1014.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-node": "^3.972.24", "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", "@aws-sdk/middleware-expect-continue": "^3.972.8", "@aws-sdk/middleware-flexible-checksums": "^3.974.3", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-location-constraint": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/middleware-ssec": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/signature-v4-multi-region": "^3.996.11", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-blob-browser": "^4.2.13", "@smithy/hash-node": "^4.2.12", "@smithy/hash-stream-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/md5-js": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-0XLrOT4Cm3NEhhiME7l/8LbTXS4KdsbR4dSrY207KNKTcHLLTZ9EXt4ZpgnTfLvWQF3pGP2us4Zi1fYLo0N+Ow=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1014.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-node": "^3.972.24", "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", "@aws-sdk/middleware-expect-continue": "^3.972.8", "@aws-sdk/middleware-flexible-checksums": "^3.974.3", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-location-constraint": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/middleware-ssec": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/signature-v4-multi-region": "^3.996.11", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-blob-browser": "^4.2.13", "@smithy/hash-node": "^4.2.12", "@smithy/hash-stream-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/md5-js": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-0XLrOT4Cm3NEhhiME7l/8LbTXS4KdsbR4dSrY207KNKTcHLLTZ9EXt4ZpgnTfLvWQF3pGP2us4Zi1fYLo0N+Ow=="],
|
||||
|
||||
"@browseros/eval/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
@@ -5228,100 +5213,6 @@
|
||||
|
||||
"@browseros/agent/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.24", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-ini": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/crc64-nvme": "^3.972.5", "@aws-sdk/types": "^3.973.6", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-fB7FNLH1+VPUs0QL3PLrHW+DD4gKu6daFgWtyq3R0Y0Lx8DLZPvyGAxCZNFBxH+M2xt9KvBJX6USwjuqvitmCQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-50QgHGPQAb2veqFOmTF1A3GsAklLHZXL47KbY35khIkfbXH5PLvqpEc/gOAEBPj/yFxrlgxz/8mqWcWTNxBkwQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-dLTWy6IfAMhNiSEvMr07g/qZ54be6pLqlxVblbF6AzafmmGAzMMj8qMoY9B4+YgT+gY9IcuxZslNh03L6PyMCQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/config-resolver": "^4.4.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.11", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-SKgZY7x6AloLUXO20FJGnkKJ3a6CXzNDt6PYs2yqoPzgU0xKWcUoGGJGEBTsfM5eihKW42lbwp+sXzACLbSsaA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.10", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-E99zeTscCc+pTMfsvnfi6foPpKmdD1cZfOC7/P8UUrjsoQdg9VEWPRD+xdFduKnfPXwcvby58AlO9jwwF6U96g=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/config-resolver": ["@smithy/config-resolver@4.4.13", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/core": ["@smithy/core@3.23.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.15", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.13", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/hash-node": ["@smithy/hash-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/md5-js": ["@smithy/md5-js@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.27", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-serde": "^4.2.15", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.44", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/service-error-classification": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.15", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.12", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.0", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/protocol-http": ["@smithy/protocol-http@5.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/smithy-client": ["@smithy/smithy-client@4.12.7", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-stack": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/url-parser": ["@smithy/url-parser@4.2.12", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.43", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.47", "", { "dependencies": { "@smithy/config-resolver": "^4.4.13", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-middleware": ["@smithy/util-middleware@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-retry": ["@smithy/util-retry@4.2.12", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-stream": ["@smithy/util-stream@4.5.20", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-waiter": ["@smithy/util-waiter@4.2.13", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.24", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-ini": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw=="],
|
||||
@@ -5706,70 +5597,6 @@
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BkAfKq8Bd4shCtec1usNz//urPJF/SZy14qJyxkSaRJQ/Vv1gVh0VZSTmS7aE6aLMELkFV5wHHrS9ZcdG8Kxsg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-4XZ3+Gu5DY8/n8zQFHBgcKTF7hWQl42G6CY9xfXVo2d25FM/lYkpmuzhYopYoPL1ITWkJ2OSBQfYEu5JRfHOhA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-login": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-PZLSmU0JFpNCDFReidBezsgL5ji9jOBry8CnZdw4Jj6d0K2z3Ftnp44NXgADqYx5BLMu/ZHujfeJReaDoV+IwQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-nRxbeOJ1E1gVA0lNQezuMVndx+ZcuyaW/RB05pUsznN5BxykSlH6KkZ/7Ca/ubJf3i5N3p0gwNO5zgPSCzj+ww=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/token-providers": "3.1014.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-APUccADuYPLL0f2htpM8Z4czabSmHOdo4r41W6lKEZdy++cNJ42Radqy6x4TopENzr3hR6WYMyhiuiqtbf/nAA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-H5JNqtIwOu/feInmMMWcK0dL5r897ReEn7n2m16Dd0DPD9gA2Hg8Cq4UDzZ/9OzaLh/uqBM6seixz0U6Fi2Eag=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-bucket-endpoint/@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-flexible-checksums/@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.5", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3/@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-browser/@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-node/@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/fetch-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-endpoint/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-config-provider/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-config-provider/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-http-handler/@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/url-parser/@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-browser/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-node/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-waiter/@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
@@ -5878,22 +5705,6 @@
|
||||
|
||||
"wxt/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1014.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-gHTHNUoaOGNrSWkl32A7wFsU78jlNTlqMccLu0byUk5CysYYXaxNMIonIVr4YcykC7vgtDS5ABuz83giy6fzJA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-browser/@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-node/@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg=="],
|
||||
@@ -5922,8 +5733,6 @@
|
||||
|
||||
"publish-browser-extension/listr2/cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
|
||||
|
||||
"@google/genai/google-auth-library/gaxios/rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"dev:watch": "./tools/dev/run.sh watch",
|
||||
"dev:watch:new": "./tools/dev/run.sh watch --new",
|
||||
"dev:manual": "./tools/dev/run.sh watch --manual",
|
||||
"dev:setup": "./tools/dev/setup.sh",
|
||||
"test:env": "./tools/dev/run.sh test",
|
||||
"test:cleanup": "./tools/dev/run.sh cleanup",
|
||||
"start:server": "bun run --filter @browseros/server --elide-lines=0 start",
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# R2 / Cloudflare object storage - required by upload and publish jobs
|
||||
R2_ACCOUNT_ID=
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
R2_BUCKET=browseros
|
||||
|
||||
# Public CDN base - used by cache:sync to GET manifest and artifacts
|
||||
R2_PUBLIC_BASE_URL=https://cdn.browseros.com
|
||||
|
||||
# Dev mode routes cache to ~/.browseros-dev/cache/; unset for ~/.browseros/cache/
|
||||
NODE_ENV=development
|
||||
@@ -1,78 +0,0 @@
|
||||
# @browseros/build-tools
|
||||
|
||||
Builds agent image tarballs, publishes release artifacts to R2, and hydrates the local dev cache for agent tarballs.
|
||||
|
||||
The BrowserOS VM is defined by a committed Lima template at `template/browseros-vm.yaml`. There is no custom disk build step; `limactl` consumes the template directly at runtime.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cp packages/build-tools/.env.sample packages/build-tools/.env
|
||||
bun install
|
||||
```
|
||||
|
||||
## Dev loop against the Lima template
|
||||
|
||||
Requires `limactl` on PATH. It is bundled with the server; for bare-worktree use, install Lima with Homebrew.
|
||||
|
||||
```bash
|
||||
brew install lima
|
||||
```
|
||||
|
||||
```bash
|
||||
limactl start \
|
||||
--name browseros-vm-dev \
|
||||
packages/browseros-agent/packages/build-tools/template/browseros-vm.yaml
|
||||
|
||||
limactl shell browseros-vm-dev nerdctl info
|
||||
|
||||
SOCK="$(limactl list browseros-vm-dev --format '{{.Dir}}')/sock/containerd.sock"
|
||||
test -S "$SOCK"
|
||||
|
||||
bun run --filter @browseros/build-tools build:tarball -- --agent openclaw --arch arm64
|
||||
limactl shell browseros-vm-dev nerdctl load -i "$(ls dist/images/openclaw-*-arm64.tar.gz | head -1)"
|
||||
|
||||
limactl delete --force browseros-vm-dev
|
||||
```
|
||||
|
||||
## Build an agent tarball
|
||||
|
||||
The BrowserOS VM uses containerd + nerdctl. This host-side tarball builder still requires `podman` to pull and save OCI archives for release packaging.
|
||||
|
||||
```bash
|
||||
bun run --filter @browseros/build-tools build:tarball -- --agent openclaw --arch arm64
|
||||
```
|
||||
|
||||
## Smoke test an agent tarball
|
||||
|
||||
```bash
|
||||
bun run --filter @browseros/build-tools smoke:tarball -- --agent openclaw --arch arm64 --tarball ./dist/images/openclaw-2026.4.12-arm64.tar.gz
|
||||
```
|
||||
|
||||
## Emit a manifest
|
||||
|
||||
```bash
|
||||
bun run --filter @browseros/build-tools emit-manifest -- --dist-dir packages/build-tools/dist
|
||||
```
|
||||
|
||||
Publish workflows can update one agent slice at a time. Sliced publishing requires an existing R2 `vm/manifest.json` baseline; bootstrap first releases with `--slice full`.
|
||||
|
||||
```bash
|
||||
bun run --filter @browseros/build-tools emit-manifest -- --slice agents:openclaw --merge-from https://cdn.browseros.com/vm/manifest.json
|
||||
```
|
||||
|
||||
## Sync the dev cache
|
||||
|
||||
```bash
|
||||
NODE_ENV=development bun run --filter @browseros/build-tools cache:sync
|
||||
```
|
||||
|
||||
Pulls the published manifest and tarballs from R2 (`cdn.browseros.com/vm/`). Development cache files land under `~/.browseros-dev/cache/vm/images/`. Production-mode cache files land under `~/.browseros/cache/vm/images/`.
|
||||
|
||||
## Seed the dev cache from a local build
|
||||
|
||||
```bash
|
||||
NODE_ENV=development bun run --filter @browseros/build-tools dev:seed:tarball
|
||||
```
|
||||
|
||||
`dev:seed:tarball` hardcodes `arm64` (all devs are on Apple Silicon), builds the configured agent tarball, skips R2 entirely, and writes an arm64-only manifest + tarball into `~/.browseros-dev/cache/vm/`. It refuses to run unless `NODE_ENV=development`. Use this when you want to test the server against the latest configured agent tarball without publishing.
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"name": "openclaw",
|
||||
"image": "ghcr.io/openclaw/openclaw",
|
||||
"version": "2026.4.12"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "@browseros/build-tools",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "BrowserOS release artifact producer and dev cache sync",
|
||||
"scripts": {
|
||||
"build:tarball": "bun run scripts/build-tarball.ts",
|
||||
"emit-manifest": "bun run scripts/emit-manifest.ts",
|
||||
"upload": "bun run scripts/upload-to-r2.ts",
|
||||
"download": "bun run scripts/download-from-r2.ts",
|
||||
"cache:sync": "bun run scripts/cache-sync.ts",
|
||||
"dev:seed:tarball": "bun run scripts/seed-dev-agent-tarball.ts",
|
||||
"smoke:tarball": "bun run scripts/smoke-tarball.ts",
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.933.0",
|
||||
"@browseros/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.3"
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { parseArch, podmanArch } from './common/arch'
|
||||
import { type Bundle, tarballKey } from './common/manifest'
|
||||
import { sha256File } from './common/sha256'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
agent: { type: 'string' },
|
||||
arch: { type: 'string' },
|
||||
'output-dir': { type: 'string', default: './dist/images' },
|
||||
},
|
||||
})
|
||||
|
||||
if (!values.agent || !values.arch) {
|
||||
console.error(
|
||||
'usage: build:tarball -- --agent <name> --arch <arm64|x64> [--output-dir ./dist/images]',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const arch = parseArch(values.arch)
|
||||
const outDir = values['output-dir']
|
||||
await mkdir(outDir, { recursive: true })
|
||||
|
||||
const pkgRoot = path.resolve(import.meta.dir, '..')
|
||||
const bundle = JSON.parse(
|
||||
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
|
||||
) as Bundle
|
||||
const agent = bundle.agents.find(({ name }) => name === values.agent)
|
||||
if (!agent) throw new Error(`unknown agent: ${values.agent}`)
|
||||
|
||||
const ref = `${agent.image}:${agent.version}`
|
||||
const tarballPath = path.join(
|
||||
outDir,
|
||||
path.basename(tarballKey(agent.name, agent.version, arch)),
|
||||
)
|
||||
const tarPath = tarballPath.slice(0, -'.gz'.length)
|
||||
|
||||
await rm(tarballPath, { force: true })
|
||||
await rm(`${tarballPath}.sha256`, { force: true })
|
||||
await rm(tarPath, { force: true })
|
||||
await spawnChecked([
|
||||
'podman',
|
||||
'pull',
|
||||
'--os',
|
||||
'linux',
|
||||
'--arch',
|
||||
podmanArch(arch),
|
||||
ref,
|
||||
])
|
||||
await spawnChecked([
|
||||
'podman',
|
||||
'save',
|
||||
'--format=oci-archive',
|
||||
'--output',
|
||||
tarPath,
|
||||
ref,
|
||||
])
|
||||
await spawnChecked(['gzip', '-9', '-f', tarPath])
|
||||
|
||||
const sha = await sha256File(tarballPath)
|
||||
const size = (await stat(tarballPath)).size
|
||||
await writeFile(
|
||||
`${tarballPath}.sha256`,
|
||||
`${sha} ${path.basename(tarballPath)}\n`,
|
||||
)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
key: tarballKey(agent.name, agent.version, arch),
|
||||
path: tarballPath,
|
||||
sha256: sha,
|
||||
sizeBytes: size,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
async function spawnChecked(argv: string[]): Promise<void> {
|
||||
const proc = Bun.spawn(argv, {
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
})
|
||||
const code = await proc.exited
|
||||
if (code !== 0) throw new Error(`${argv[0]} exited ${code}`)
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import { homedir, arch as hostArch } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import { ARCHES, type Arch } from './common/arch'
|
||||
import { fetchWithTimeout } from './common/fetch'
|
||||
import type { AgentManifest, Artifact } from './common/manifest'
|
||||
import { verifySha256 } from './common/sha256'
|
||||
|
||||
type ChunkSink = ReturnType<ReturnType<typeof Bun.file>['writer']>
|
||||
|
||||
export interface PlanItem {
|
||||
key: string
|
||||
destPath: string
|
||||
sha256: string
|
||||
}
|
||||
|
||||
export function planSync(opts: {
|
||||
local: AgentManifest | null
|
||||
remote: AgentManifest
|
||||
cacheRoot: string
|
||||
arches: Arch[]
|
||||
}): PlanItem[] {
|
||||
const out: PlanItem[] = []
|
||||
for (const arch of opts.arches) {
|
||||
for (const [name, agent] of Object.entries(opts.remote.agents)) {
|
||||
maybeAdd(
|
||||
out,
|
||||
agent.tarballs[arch],
|
||||
opts.local?.agents[name]?.tarballs[arch],
|
||||
opts.cacheRoot,
|
||||
)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function selectSyncArches(
|
||||
allArches: boolean,
|
||||
rawHostArch = hostArch(),
|
||||
): Arch[] {
|
||||
if (allArches) return [...ARCHES]
|
||||
if (rawHostArch === 'arm64') return ['arm64']
|
||||
if (rawHostArch === 'x64' || rawHostArch === 'ia32') return ['x64']
|
||||
throw new Error(`unsupported host arch: ${rawHostArch}`)
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
'manifest-url': { type: 'string' },
|
||||
'all-arches': { type: 'boolean' },
|
||||
'cache-dir': { type: 'string' },
|
||||
},
|
||||
})
|
||||
|
||||
const cdnBase =
|
||||
process.env.R2_PUBLIC_BASE_URL?.trim() ?? 'https://cdn.browseros.com'
|
||||
const manifestUrl = values['manifest-url'] ?? `${cdnBase}/vm/manifest.json`
|
||||
const cacheRoot = values['cache-dir'] ?? getCacheDir()
|
||||
const arches = selectSyncArches(values['all-arches'] ?? false)
|
||||
|
||||
const response = await fetchWithTimeout(manifestUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`manifest fetch failed: ${manifestUrl} (${response.status})`,
|
||||
)
|
||||
}
|
||||
const remote = (await response.json()) as AgentManifest
|
||||
|
||||
const localManifestPath = path.join(cacheRoot, 'vm', 'manifest.json')
|
||||
const local = await readLocalManifest(localManifestPath)
|
||||
const plan = planSync({ local, remote, cacheRoot, arches })
|
||||
|
||||
if (plan.length === 0) {
|
||||
console.log('agent cache up to date')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.log(`syncing ${plan.length} agent artifact(s)`)
|
||||
for (const item of plan) {
|
||||
await mkdir(path.dirname(item.destPath), { recursive: true })
|
||||
const partial = `${item.destPath}.partial`
|
||||
await downloadToFile(`${cdnBase}/${item.key}`, partial)
|
||||
await verifySha256(partial, item.sha256)
|
||||
await rename(partial, item.destPath)
|
||||
console.log(`synced ${item.key}`)
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(localManifestPath), { recursive: true })
|
||||
await writeFile(localManifestPath, `${JSON.stringify(remote, null, 2)}\n`)
|
||||
console.log(`manifest written to ${localManifestPath}`)
|
||||
}
|
||||
|
||||
function maybeAdd(
|
||||
out: PlanItem[],
|
||||
remote: Artifact,
|
||||
local: Artifact | undefined,
|
||||
cacheRoot: string,
|
||||
): void {
|
||||
if (local?.sha256 === remote.sha256) return
|
||||
out.push({
|
||||
key: remote.key,
|
||||
destPath: path.join(cacheRoot, remote.key),
|
||||
sha256: remote.sha256,
|
||||
})
|
||||
}
|
||||
|
||||
function getCacheDir(): string {
|
||||
const dirName =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? PATHS.DEV_BROWSEROS_DIR_NAME
|
||||
: PATHS.BROWSEROS_DIR_NAME
|
||||
return path.join(homedir(), dirName, PATHS.CACHE_DIR_NAME)
|
||||
}
|
||||
|
||||
export async function readLocalManifest(
|
||||
manifestPath: string,
|
||||
): Promise<AgentManifest | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(manifestPath, 'utf8')) as AgentManifest
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadToFile(url: string, dest: string): Promise<void> {
|
||||
const response = await fetchWithTimeout(url)
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`download failed: ${url} (${response.status})`)
|
||||
}
|
||||
|
||||
const sink = Bun.file(dest).writer()
|
||||
const reader = response.body.getReader()
|
||||
try {
|
||||
await pumpStream(reader, sink)
|
||||
} finally {
|
||||
await sink.end()
|
||||
}
|
||||
}
|
||||
|
||||
async function pumpStream(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
sink: ChunkSink,
|
||||
): Promise<void> {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
sink.write(value)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export type Arch = 'arm64' | 'x64'
|
||||
|
||||
export const ARCHES: readonly Arch[] = ['arm64', 'x64']
|
||||
|
||||
export function parseArch(raw: string): Arch {
|
||||
if (raw === 'arm64' || raw === 'x64') return raw
|
||||
throw new Error(`unknown arch: ${raw} (expected arm64|x64)`)
|
||||
}
|
||||
|
||||
export function podmanArch(arch: Arch): 'arm64' | 'amd64' {
|
||||
return arch === 'x64' ? 'amd64' : 'arm64'
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
export async function fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs = 30_000,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: init.signal ?? controller.signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if ((error as { name?: string }).name === 'AbortError') {
|
||||
throw new Error(`fetch timed out after ${timeoutMs}ms: ${url}`)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { ARCHES, type Arch } from './arch'
|
||||
|
||||
export interface Artifact {
|
||||
key: string
|
||||
sha256: string
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export interface AgentEntry {
|
||||
image: string
|
||||
version: string
|
||||
tarballs: Record<Arch, Artifact>
|
||||
}
|
||||
|
||||
export interface AgentManifest {
|
||||
schemaVersion: 2
|
||||
updatedAt: string
|
||||
agents: Record<string, AgentEntry>
|
||||
}
|
||||
|
||||
export interface BundleAgent {
|
||||
name: string
|
||||
image: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface Bundle {
|
||||
agents: BundleAgent[]
|
||||
}
|
||||
|
||||
export interface ArtifactInput {
|
||||
sha256: string
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export interface ArtifactInputs {
|
||||
agents: Record<string, Record<Arch, ArtifactInput>>
|
||||
}
|
||||
|
||||
export function tarballKey(name: string, version: string, arch: Arch): string {
|
||||
return `vm/images/${name}-${version}-${arch}.tar.gz`
|
||||
}
|
||||
|
||||
export function buildManifest(
|
||||
bundle: Bundle,
|
||||
inputs: ArtifactInputs,
|
||||
now: Date = new Date(),
|
||||
): AgentManifest {
|
||||
const agents: Record<string, AgentEntry> = {}
|
||||
for (const agent of bundle.agents) {
|
||||
const tarballs = {} as Record<Arch, Artifact>
|
||||
for (const arch of ARCHES) {
|
||||
const entry = inputs.agents[agent.name]?.[arch]
|
||||
if (!entry) {
|
||||
throw new Error(`missing tarball inputs for ${agent.name}/${arch}`)
|
||||
}
|
||||
tarballs[arch] = {
|
||||
key: tarballKey(agent.name, agent.version, arch),
|
||||
sha256: entry.sha256,
|
||||
sizeBytes: entry.sizeBytes,
|
||||
}
|
||||
}
|
||||
agents[agent.name] = {
|
||||
image: agent.image,
|
||||
version: agent.version,
|
||||
tarballs,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
updatedAt: now.toISOString(),
|
||||
agents,
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import {
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3'
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) throw new Error(`missing env var: ${name}`)
|
||||
return value
|
||||
}
|
||||
|
||||
export function createR2Client(): S3Client {
|
||||
return new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${required('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: required('R2_ACCESS_KEY_ID'),
|
||||
secretAccessKey: required('R2_SECRET_ACCESS_KEY'),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getBucket(): string {
|
||||
return required('R2_BUCKET')
|
||||
}
|
||||
|
||||
export function getCdnBase(): string {
|
||||
return process.env.R2_PUBLIC_BASE_URL?.trim() ?? 'https://cdn.browseros.com'
|
||||
}
|
||||
|
||||
export async function putFile(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
filePath: string,
|
||||
contentType: string,
|
||||
): Promise<void> {
|
||||
const { size } = await stat(filePath)
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: createReadStream(filePath),
|
||||
ContentLength: size,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function putBody(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
body: string,
|
||||
contentType: string,
|
||||
): Promise<void> {
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentLength: Buffer.byteLength(body),
|
||||
ContentType: contentType,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function getBody(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({ Bucket: bucket, Key: key }),
|
||||
)
|
||||
const body = response.Body as
|
||||
| { transformToByteArray(): Promise<Uint8Array> }
|
||||
| undefined
|
||||
if (!body) throw new Error(`missing response body for R2 key: ${key}`)
|
||||
const bytes = await body.transformToByteArray()
|
||||
return new TextDecoder().decode(bytes)
|
||||
} catch (error) {
|
||||
const cause = error as {
|
||||
name?: string
|
||||
$metadata?: { httpStatusCode?: number }
|
||||
}
|
||||
if (cause.name === 'NoSuchKey' || cause.$metadata?.httpStatusCode === 404) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
|
||||
export async function sha256File(path: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(path)) {
|
||||
hash.update(chunk)
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
export async function verifySha256(
|
||||
path: string,
|
||||
expected: string,
|
||||
): Promise<void> {
|
||||
const actual = await sha256File(path)
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`sha256 mismatch for ${path}: expected ${expected}, got ${actual}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { createR2Client, getBody, getBucket } from './common/r2'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
key: { type: 'string' },
|
||||
out: { type: 'string' },
|
||||
},
|
||||
})
|
||||
|
||||
if (!values.key || !values.out) {
|
||||
console.error('usage: download -- --key <r2-key> --out <path>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const body = await getBody(createR2Client(), getBucket(), values.key)
|
||||
if (body === null) {
|
||||
throw new Error(
|
||||
`R2 key not found: ${values.key}. Publish a full manifest before publishing slices.`,
|
||||
)
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(values.out), { recursive: true })
|
||||
await writeFile(values.out, body)
|
||||
console.log(`downloaded ${values.key} to ${values.out}`)
|
||||
@@ -1,142 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { ARCHES } from './common/arch'
|
||||
import { fetchWithTimeout } from './common/fetch'
|
||||
import {
|
||||
type AgentEntry,
|
||||
type AgentManifest,
|
||||
type ArtifactInputs,
|
||||
type Bundle,
|
||||
type BundleAgent,
|
||||
buildManifest,
|
||||
tarballKey,
|
||||
} from './common/manifest'
|
||||
import { sha256File } from './common/sha256'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
'dist-dir': { type: 'string', default: './dist' },
|
||||
out: { type: 'string' },
|
||||
slice: { type: 'string', default: 'full' },
|
||||
'merge-from': { type: 'string' },
|
||||
},
|
||||
})
|
||||
|
||||
const distDir = values['dist-dir']
|
||||
const slice = values.slice
|
||||
const pkgRoot = path.resolve(import.meta.dir, '..')
|
||||
const bundle = JSON.parse(
|
||||
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
|
||||
) as Bundle
|
||||
|
||||
if (slice !== 'full' && !slice.startsWith('agents:')) {
|
||||
throw new Error(`unknown slice: ${slice}`)
|
||||
}
|
||||
|
||||
const baseline = values['merge-from']
|
||||
? await loadBaseline(values['merge-from'])
|
||||
: null
|
||||
if (slice !== 'full' && !baseline) {
|
||||
throw new Error(`--slice ${slice} requires --merge-from`)
|
||||
}
|
||||
|
||||
const manifest = await buildSlicedManifest({ bundle, distDir, slice, baseline })
|
||||
const outPath = values.out ?? path.join(distDir, 'manifest.json')
|
||||
await mkdir(path.dirname(outPath), { recursive: true })
|
||||
await writeFile(outPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
console.log(`wrote ${outPath} (slice=${slice})`)
|
||||
|
||||
async function buildSlicedManifest(opts: {
|
||||
bundle: Bundle
|
||||
distDir: string
|
||||
slice: string
|
||||
baseline: AgentManifest | null
|
||||
}): Promise<AgentManifest> {
|
||||
if (opts.slice === 'full') {
|
||||
return buildManifest(
|
||||
opts.bundle,
|
||||
await readAllInputs(opts.bundle, opts.distDir),
|
||||
)
|
||||
}
|
||||
|
||||
const baseline = opts.baseline
|
||||
if (!baseline) throw new Error(`--slice ${opts.slice} requires --merge-from`)
|
||||
const updatedAt = new Date().toISOString()
|
||||
|
||||
if (opts.slice.startsWith('agents:')) {
|
||||
const name = opts.slice.slice('agents:'.length)
|
||||
const agent = opts.bundle.agents.find((entry) => entry.name === name)
|
||||
if (!agent) throw new Error(`unknown agent: ${name}`)
|
||||
|
||||
return {
|
||||
...baseline,
|
||||
schemaVersion: 2,
|
||||
updatedAt,
|
||||
agents: {
|
||||
...baseline.agents,
|
||||
[name]: await readAgentEntry(agent, opts.distDir),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`unknown slice: ${opts.slice}`)
|
||||
}
|
||||
|
||||
async function readAllInputs(
|
||||
bundle: Bundle,
|
||||
distDir: string,
|
||||
): Promise<ArtifactInputs> {
|
||||
const agents: ArtifactInputs['agents'] = {}
|
||||
for (const agent of bundle.agents) {
|
||||
agents[agent.name] = {} as ArtifactInputs['agents'][string]
|
||||
for (const arch of ARCHES) {
|
||||
const artifactPath = path.join(
|
||||
distDir,
|
||||
'images',
|
||||
path.basename(tarballKey(agent.name, agent.version, arch)),
|
||||
)
|
||||
agents[agent.name][arch] = await readArtifactInput(artifactPath)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agents,
|
||||
}
|
||||
}
|
||||
|
||||
async function readAgentEntry(
|
||||
agent: BundleAgent,
|
||||
distDir: string,
|
||||
): Promise<AgentEntry> {
|
||||
const tarballs = {} as AgentEntry['tarballs']
|
||||
for (const arch of ARCHES) {
|
||||
const key = tarballKey(agent.name, agent.version, arch)
|
||||
const artifactPath = path.join(distDir, 'images', path.basename(key))
|
||||
tarballs[arch] = { key, ...(await readArtifactInput(artifactPath)) }
|
||||
}
|
||||
return { image: agent.image, version: agent.version, tarballs }
|
||||
}
|
||||
|
||||
async function readArtifactInput(
|
||||
filePath: string,
|
||||
): Promise<{ sha256: string; sizeBytes: number }> {
|
||||
return {
|
||||
sha256: await sha256File(filePath),
|
||||
sizeBytes: (await stat(filePath)).size,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBaseline(src: string): Promise<AgentManifest> {
|
||||
if (src.startsWith('http://') || src.startsWith('https://')) {
|
||||
const response = await fetchWithTimeout(src)
|
||||
if (!response.ok) {
|
||||
throw new Error(`baseline fetch failed: ${src} (${response.status})`)
|
||||
}
|
||||
return (await response.json()) as AgentManifest
|
||||
}
|
||||
|
||||
return JSON.parse(await readFile(src, 'utf8')) as AgentManifest
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
import { copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import type { Arch } from './common/arch'
|
||||
import {
|
||||
type AgentEntry,
|
||||
type AgentManifest,
|
||||
type Artifact,
|
||||
type Bundle,
|
||||
type BundleAgent,
|
||||
tarballKey,
|
||||
} from './common/manifest'
|
||||
import { sha256File, verifySha256 } from './common/sha256'
|
||||
|
||||
export const DEV_ARCH: Arch = 'arm64'
|
||||
|
||||
export interface BuiltAgentArtifact {
|
||||
agent: BundleAgent
|
||||
key: string
|
||||
path: string
|
||||
sha256: string
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export interface DevAgentEntry extends Omit<AgentEntry, 'tarballs'> {
|
||||
tarballs: Partial<Record<Arch, Artifact>>
|
||||
}
|
||||
|
||||
export interface DevAgentManifest extends Omit<AgentManifest, 'agents'> {
|
||||
agents: Record<string, DevAgentEntry>
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await seedDevAgentTarballs()
|
||||
}
|
||||
|
||||
export async function seedDevAgentTarballs(): Promise<void> {
|
||||
assertDevelopment()
|
||||
|
||||
const pkgRoot = path.resolve(import.meta.dir, '..')
|
||||
const bundle = await readBundle(pkgRoot)
|
||||
const distImagesDir = path.join(pkgRoot, 'dist', 'images')
|
||||
const cacheRoot = devCacheRoot()
|
||||
const artifacts: BuiltAgentArtifact[] = []
|
||||
|
||||
for (const agent of bundle.agents) {
|
||||
await buildTarball(pkgRoot, agent, distImagesDir)
|
||||
const artifact = await readBuiltArtifact(agent, distImagesDir)
|
||||
await seedArtifact(cacheRoot, artifact)
|
||||
artifacts.push(artifact)
|
||||
}
|
||||
|
||||
const manifestPath = path.join(cacheRoot, 'vm', 'manifest.json')
|
||||
await mkdir(path.dirname(manifestPath), { recursive: true })
|
||||
await writeFile(
|
||||
manifestPath,
|
||||
`${JSON.stringify(buildDevManifest(artifacts), null, 2)}\n`,
|
||||
)
|
||||
console.log(`manifest written to ${manifestPath}`)
|
||||
}
|
||||
|
||||
export function buildDevManifest(
|
||||
artifacts: BuiltAgentArtifact[],
|
||||
now: Date = new Date(),
|
||||
): DevAgentManifest {
|
||||
const agents: Record<string, DevAgentEntry> = {}
|
||||
for (const artifact of artifacts) {
|
||||
agents[artifact.agent.name] = {
|
||||
image: artifact.agent.image,
|
||||
version: artifact.agent.version,
|
||||
tarballs: {
|
||||
[DEV_ARCH]: {
|
||||
key: artifact.key,
|
||||
sha256: artifact.sha256,
|
||||
sizeBytes: artifact.sizeBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
updatedAt: now.toISOString(),
|
||||
agents,
|
||||
}
|
||||
}
|
||||
|
||||
async function readBundle(pkgRoot: string): Promise<Bundle> {
|
||||
return JSON.parse(
|
||||
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
|
||||
) as Bundle
|
||||
}
|
||||
|
||||
async function buildTarball(
|
||||
pkgRoot: string,
|
||||
agent: BundleAgent,
|
||||
outputDir: string,
|
||||
): Promise<void> {
|
||||
console.log(`building ${agent.name} ${DEV_ARCH} tarball`)
|
||||
await spawnChecked(
|
||||
[
|
||||
'bun',
|
||||
'run',
|
||||
'scripts/build-tarball.ts',
|
||||
'--',
|
||||
'--agent',
|
||||
agent.name,
|
||||
'--arch',
|
||||
DEV_ARCH,
|
||||
'--output-dir',
|
||||
outputDir,
|
||||
],
|
||||
pkgRoot,
|
||||
)
|
||||
}
|
||||
|
||||
async function readBuiltArtifact(
|
||||
agent: BundleAgent,
|
||||
distImagesDir: string,
|
||||
): Promise<BuiltAgentArtifact> {
|
||||
const key = tarballKey(agent.name, agent.version, DEV_ARCH)
|
||||
const filePath = path.join(distImagesDir, path.basename(key))
|
||||
await assertExists(filePath, agent.name)
|
||||
return {
|
||||
agent,
|
||||
key,
|
||||
path: filePath,
|
||||
sha256: await sha256File(filePath),
|
||||
sizeBytes: (await stat(filePath)).size,
|
||||
}
|
||||
}
|
||||
|
||||
async function seedArtifact(
|
||||
cacheRoot: string,
|
||||
artifact: BuiltAgentArtifact,
|
||||
): Promise<void> {
|
||||
const dest = path.join(cacheRoot, artifact.key)
|
||||
if (await matchesExisting(dest, artifact.sha256)) {
|
||||
console.log(`cache hit: ${artifact.key}`)
|
||||
return
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(dest), { recursive: true })
|
||||
await copyFile(artifact.path, dest)
|
||||
await verifySha256(dest, artifact.sha256)
|
||||
console.log(`seeded ${artifact.key}`)
|
||||
}
|
||||
|
||||
function assertDevelopment(): void {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
'dev:seed:tarball refuses to run without NODE_ENV=development; it writes to ~/.browseros-dev/cache/vm/',
|
||||
)
|
||||
}
|
||||
|
||||
function devCacheRoot(): string {
|
||||
return path.join(
|
||||
homedir(),
|
||||
PATHS.DEV_BROWSEROS_DIR_NAME,
|
||||
PATHS.CACHE_DIR_NAME,
|
||||
)
|
||||
}
|
||||
|
||||
async function assertExists(
|
||||
filePath: string,
|
||||
agentName: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await stat(filePath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
throw new Error(`build did not produce ${agentName} tarball at ${filePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function matchesExisting(
|
||||
filePath: string,
|
||||
expectedSha: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return (await sha256File(filePath)) === expectedSha
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnChecked(argv: string[], cwd: string): Promise<void> {
|
||||
const proc = Bun.spawn(argv, {
|
||||
cwd,
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
})
|
||||
const code = await proc.exited
|
||||
if (code !== 0) {
|
||||
throw new Error(`${argv.join(' ')} exited with code ${code}`)
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
import { createReadStream, createWriteStream } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { createGunzip } from 'node:zlib'
|
||||
import { parseArch, podmanArch } from './common/arch'
|
||||
import type { Bundle } from './common/manifest'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
agent: { type: 'string' },
|
||||
arch: { type: 'string' },
|
||||
tarball: { type: 'string' },
|
||||
},
|
||||
})
|
||||
|
||||
if (!values.agent || !values.arch || !values.tarball) {
|
||||
console.error(
|
||||
'usage: smoke:tarball -- --agent <name> --arch <arm64|x64> --tarball <path.tar.gz>',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const arch = parseArch(values.arch)
|
||||
const pkgRoot = path.resolve(import.meta.dir, '..')
|
||||
const bundle = JSON.parse(
|
||||
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
|
||||
) as Bundle
|
||||
const agent = bundle.agents.find(({ name }) => name === values.agent)
|
||||
if (!agent) throw new Error(`unknown agent: ${values.agent}`)
|
||||
|
||||
const ref = `${agent.image}:${agent.version}`
|
||||
const tarball = await maybeDecompress(values.tarball)
|
||||
|
||||
try {
|
||||
await spawnChecked(['podman', 'rmi', '-f', ref]).catch(() => {})
|
||||
await spawnChecked(['podman', 'load', '--input', tarball.path])
|
||||
const inspected = await inspectImage(ref)
|
||||
if (inspected.Os !== 'linux') {
|
||||
throw new Error(`expected linux image, got ${inspected.Os ?? '<missing>'}`)
|
||||
}
|
||||
if (inspected.Architecture !== podmanArch(arch)) {
|
||||
throw new Error(
|
||||
`expected ${podmanArch(arch)} image, got ${inspected.Architecture ?? '<missing>'}`,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await spawnChecked(['podman', 'rmi', '-f', ref]).catch(() => {})
|
||||
if (tarball.cleanupDir) {
|
||||
await rm(tarball.cleanupDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
console.log('tarball smoke test passed')
|
||||
|
||||
async function maybeDecompress(
|
||||
tarballPath: string,
|
||||
): Promise<{ path: string; cleanupDir?: string }> {
|
||||
if (!tarballPath.endsWith('.gz')) return { path: tarballPath }
|
||||
|
||||
const cleanupDir = await mkdtemp(path.join(tmpdir(), 'browseros-tar-smoke-'))
|
||||
const tarPath = path.join(cleanupDir, 'image.tar')
|
||||
await pipeline(
|
||||
createReadStream(tarballPath),
|
||||
createGunzip(),
|
||||
createWriteStream(tarPath),
|
||||
)
|
||||
return { path: tarPath, cleanupDir }
|
||||
}
|
||||
|
||||
async function inspectImage(ref: string): Promise<{
|
||||
Architecture?: string
|
||||
Os?: string
|
||||
}> {
|
||||
const stdout = await spawnCapture([
|
||||
'podman',
|
||||
'inspect',
|
||||
'--type',
|
||||
'image',
|
||||
'--format',
|
||||
'{{json .}}',
|
||||
ref,
|
||||
])
|
||||
return JSON.parse(stdout) as { Architecture?: string; Os?: string }
|
||||
}
|
||||
|
||||
async function spawnCapture(argv: string[]): Promise<string> {
|
||||
const proc = Bun.spawn(argv, { stdout: 'pipe', stderr: 'pipe' })
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
])
|
||||
if (code !== 0) {
|
||||
throw new Error(
|
||||
`${argv[0]} exited ${code}\n${stderr.trim() || stdout.trim()}`,
|
||||
)
|
||||
}
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
async function spawnChecked(argv: string[]): Promise<void> {
|
||||
await spawnCapture(argv)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
import { parseArgs } from 'node:util'
|
||||
import { createR2Client, getBucket, putBody, putFile } from './common/r2'
|
||||
import { sha256File } from './common/sha256'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
file: { type: 'string' },
|
||||
key: { type: 'string' },
|
||||
'content-type': { type: 'string' },
|
||||
'sidecar-sha': { type: 'boolean' },
|
||||
},
|
||||
})
|
||||
|
||||
if (!values.file || !values.key) {
|
||||
throw new Error('--file and --key required')
|
||||
}
|
||||
|
||||
const contentType = values['content-type'] ?? 'application/octet-stream'
|
||||
const client = createR2Client()
|
||||
const bucket = getBucket()
|
||||
|
||||
try {
|
||||
await putFile(client, bucket, values.key, values.file, contentType)
|
||||
console.log(`uploaded ${values.file} to ${bucket}/${values.key}`)
|
||||
|
||||
if (values['sidecar-sha']) {
|
||||
const sha = await sha256File(values.file)
|
||||
const filename = values.file.split('/').pop() ?? values.file
|
||||
await putBody(
|
||||
client,
|
||||
bucket,
|
||||
`${values.key}.sha256`,
|
||||
`${sha} ${filename}\n`,
|
||||
'text/plain; charset=utf-8',
|
||||
)
|
||||
console.log(`uploaded sha256 to ${bucket}/${values.key}.sha256`)
|
||||
}
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
# BrowserOS VM -- consumed directly by limactl, no build step.
|
||||
|
||||
minimumLimaVersion: 2.0.0
|
||||
|
||||
vmType: vz
|
||||
cpus: 2
|
||||
memory: 2GiB
|
||||
disk: 30GiB
|
||||
|
||||
images:
|
||||
- location: "https://cloud-images.ubuntu.com/minimal/releases/noble/release-20260415/ubuntu-24.04-minimal-cloudimg-arm64.img"
|
||||
arch: aarch64
|
||||
digest: "sha256:0cc0a529a52109b52bf697a0d90bdd0f252e7ad91b3a67f70879d56d1f64e240"
|
||||
- location: "https://cloud-images.ubuntu.com/minimal/releases/noble/release-20260415/ubuntu-24.04-minimal-cloudimg-amd64.img"
|
||||
arch: x86_64
|
||||
digest: "sha256:7cbfa215a3774c46c6dc29b457f4e9667acda85fc04c7971e1e592b5056e7573"
|
||||
- location: https://cloud-images.ubuntu.com/minimal/releases/noble/release/ubuntu-24.04-minimal-cloudimg-arm64.img
|
||||
arch: aarch64
|
||||
- location: https://cloud-images.ubuntu.com/minimal/releases/noble/release/ubuntu-24.04-minimal-cloudimg-amd64.img
|
||||
arch: x86_64
|
||||
|
||||
mounts: []
|
||||
|
||||
containerd:
|
||||
system: false
|
||||
user: true
|
||||
|
||||
provision:
|
||||
- mode: system
|
||||
script: |
|
||||
#!/bin/bash
|
||||
set -eux -o pipefail
|
||||
|
||||
if [ -e /etc/browseros-vm-provisioned ]; then exit 0; fi
|
||||
|
||||
systemctl disable --now unattended-upgrades.service apt-daily.timer apt-daily-upgrade.timer || true
|
||||
systemctl disable --now snapd.service snapd.socket snapd.seeded.service || true
|
||||
|
||||
printf 'runtime:containerd-rootless\nprovisioned:%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > /etc/browseros-vm-version
|
||||
touch /etc/browseros-vm-provisioned
|
||||
|
||||
probes:
|
||||
- script: |
|
||||
#!/bin/bash
|
||||
set -eux -o pipefail
|
||||
if ! timeout 60s bash -c 'until nerdctl info >/dev/null 2>&1; do sleep 2; done'; then
|
||||
echo >&2 "nerdctl is not ready after 60s"
|
||||
exit 1
|
||||
fi
|
||||
hint: See /var/log/cloud-init-output.log inside the guest
|
||||
|
||||
portForwards:
|
||||
- guestSocket: "/run/user/{{.UID}}/containerd-rootless/containerd.sock"
|
||||
hostSocket: "{{.Dir}}/sock/containerd.sock"
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { parseArch, podmanArch } from '../scripts/common/arch'
|
||||
|
||||
describe('arch helpers', () => {
|
||||
it('normalizes BrowserOS arches for podman', () => {
|
||||
expect(podmanArch('arm64')).toBe('arm64')
|
||||
expect(podmanArch('x64')).toBe('amd64')
|
||||
})
|
||||
|
||||
it('parses supported release arches', () => {
|
||||
expect(parseArch('arm64')).toBe('arm64')
|
||||
expect(parseArch('x64')).toBe('x64')
|
||||
})
|
||||
|
||||
it('rejects unsupported release arches', () => {
|
||||
expect(() => parseArch('amd64')).toThrow('unknown arch: amd64')
|
||||
})
|
||||
})
|
||||
@@ -1,296 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
type PlanItem,
|
||||
planSync,
|
||||
readLocalManifest,
|
||||
selectSyncArches,
|
||||
} from '../scripts/cache-sync'
|
||||
import type { AgentManifest } from '../scripts/common/manifest'
|
||||
import { sha256File } from '../scripts/common/sha256'
|
||||
import { buildDevManifest } from '../scripts/seed-dev-agent-tarball'
|
||||
|
||||
const openclaw = {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
}
|
||||
|
||||
const claudeCode = {
|
||||
image: 'ghcr.io/anthropics/claude-code',
|
||||
version: '2026.4.10',
|
||||
}
|
||||
|
||||
function manifest(tarSha: string, includeSecondAgent = false): AgentManifest {
|
||||
const agents: AgentManifest['agents'] = {
|
||||
openclaw: {
|
||||
...openclaw,
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: `${tarSha}-arm64`,
|
||||
sizeBytes: 201,
|
||||
},
|
||||
x64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
sha256: `${tarSha}-x64`,
|
||||
sizeBytes: 202,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if (includeSecondAgent) {
|
||||
agents['claude-code'] = {
|
||||
...claudeCode,
|
||||
tarballs: {
|
||||
arm64: {
|
||||
key: 'vm/images/claude-code-2026.4.10-arm64.tar.gz',
|
||||
sha256: `${tarSha}-claude-arm64`,
|
||||
sizeBytes: 301,
|
||||
},
|
||||
x64: {
|
||||
key: 'vm/images/claude-code-2026.4.10-x64.tar.gz',
|
||||
sha256: `${tarSha}-claude-x64`,
|
||||
sizeBytes: 302,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents,
|
||||
}
|
||||
}
|
||||
|
||||
function keys(plan: PlanItem[]): string[] {
|
||||
return plan.map((item) => item.key)
|
||||
}
|
||||
|
||||
describe('planSync', () => {
|
||||
it('downloads every selected-arch agent artifact for a fresh cache', () => {
|
||||
const remote = manifest('t1')
|
||||
|
||||
expect(
|
||||
keys(planSync({ local: null, remote, cacheRoot: '/c', arches: ['x64'] })),
|
||||
).toEqual(['vm/images/openclaw-2026.4.12-x64.tar.gz'])
|
||||
})
|
||||
|
||||
it('does nothing when the local manifest matches the remote manifest', () => {
|
||||
const remote = manifest('t1')
|
||||
|
||||
expect(
|
||||
planSync({ local: remote, remote, cacheRoot: '/c', arches: ['x64'] }),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('downloads only agent artifacts whose sha256 changed', () => {
|
||||
const local = manifest('old-tar')
|
||||
const remote = manifest('new-tar')
|
||||
|
||||
expect(
|
||||
keys(planSync({ local, remote, cacheRoot: '/c', arches: ['x64'] })),
|
||||
).toEqual(['vm/images/openclaw-2026.4.12-x64.tar.gz'])
|
||||
})
|
||||
|
||||
it('supports syncing all release arches', () => {
|
||||
const remote = manifest('t1')
|
||||
|
||||
expect(
|
||||
planSync({
|
||||
local: null,
|
||||
remote,
|
||||
cacheRoot: '/c',
|
||||
arches: ['arm64', 'x64'],
|
||||
}),
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('selects host arch by default and both arches when requested', () => {
|
||||
expect(selectSyncArches(false, 'x64')).toEqual(['x64'])
|
||||
expect(selectSyncArches(true, 'x64')).toEqual(['arm64', 'x64'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('readLocalManifest', () => {
|
||||
let dir: string | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
if (!dir) return
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
dir = null
|
||||
})
|
||||
|
||||
it('returns null only when the local manifest is absent', async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), 'browseros-cache-manifest-'))
|
||||
|
||||
await expect(
|
||||
readLocalManifest(path.join(dir, 'missing.json')),
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces corrupt local manifest files', async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), 'browseros-cache-manifest-'))
|
||||
const manifestPath = path.join(dir, 'manifest.json')
|
||||
await writeFile(manifestPath, '{not json')
|
||||
|
||||
await expect(readLocalManifest(manifestPath)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDevManifest', () => {
|
||||
it('builds an arm64-only dev manifest from freshly built artifacts', () => {
|
||||
const manifest = buildDevManifest(
|
||||
[
|
||||
{
|
||||
agent: {
|
||||
name: 'openclaw',
|
||||
image: openclaw.image,
|
||||
version: openclaw.version,
|
||||
},
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
path: '/tmp/openclaw.tar.gz',
|
||||
sha256: 'fresh-arm64',
|
||||
sizeBytes: 404,
|
||||
},
|
||||
],
|
||||
new Date('2026-04-23T00:00:00.000Z'),
|
||||
)
|
||||
|
||||
expect(manifest.schemaVersion).toBe(2)
|
||||
expect(manifest.updatedAt).toBe('2026-04-23T00:00:00.000Z')
|
||||
expect(manifest.agents.openclaw.image).toBe(openclaw.image)
|
||||
expect(manifest.agents.openclaw.version).toBe(openclaw.version)
|
||||
expect(manifest.agents.openclaw.tarballs.arm64).toEqual({
|
||||
key: 'vm/images/openclaw-2026.4.12-arm64.tar.gz',
|
||||
sha256: 'fresh-arm64',
|
||||
sizeBytes: 404,
|
||||
})
|
||||
expect(Object.hasOwn(manifest.agents.openclaw.tarballs, 'x64')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('emit-manifest', () => {
|
||||
let dir: string | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
if (!dir) return
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
dir = null
|
||||
})
|
||||
|
||||
it('rejects the retired vm slice', async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), 'browseros-emit-vm-'))
|
||||
|
||||
const result = await runEmitManifest(
|
||||
[
|
||||
'--slice',
|
||||
'vm',
|
||||
'--dist-dir',
|
||||
path.join(dir, 'dist'),
|
||||
'--out',
|
||||
path.join(dir, 'manifest.json'),
|
||||
],
|
||||
false,
|
||||
)
|
||||
|
||||
expect(result.code).toBe(1)
|
||||
expect(result.stderr).toContain('unknown slice: vm')
|
||||
})
|
||||
|
||||
it('merges an agent slice while preserving other agents from the baseline', async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), 'browseros-emit-agent-'))
|
||||
const distDir = path.join(dir, 'dist')
|
||||
await writeAgentFiles(distDir)
|
||||
|
||||
const baseline = manifest('old-tar', true)
|
||||
const baselinePath = path.join(dir, 'baseline.json')
|
||||
const outPath = path.join(dir, 'manifest.json')
|
||||
await writeJson(baselinePath, baseline)
|
||||
|
||||
await runEmitManifest([
|
||||
'--slice',
|
||||
'agents:openclaw',
|
||||
'--dist-dir',
|
||||
distDir,
|
||||
'--merge-from',
|
||||
baselinePath,
|
||||
'--out',
|
||||
outPath,
|
||||
])
|
||||
|
||||
const merged = JSON.parse(await readFile(outPath, 'utf8')) as AgentManifest
|
||||
expect(merged.schemaVersion).toBe(2)
|
||||
expect(merged.agents['claude-code']).toEqual(baseline.agents['claude-code'])
|
||||
expect(merged.agents.openclaw.tarballs.arm64.sha256).toBe(
|
||||
await sha256File(
|
||||
path.join(distDir, 'images/openclaw-2026.4.12-arm64.tar.gz'),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('fails slice emission without a merge baseline', async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), 'browseros-emit-fail-'))
|
||||
|
||||
const result = await runEmitManifest(
|
||||
[
|
||||
'--slice',
|
||||
'agents:openclaw',
|
||||
'--dist-dir',
|
||||
path.join(dir, 'dist'),
|
||||
'--out',
|
||||
path.join(dir, 'out.json'),
|
||||
],
|
||||
false,
|
||||
)
|
||||
|
||||
expect(result.code).toBe(1)
|
||||
expect(result.stderr).toContain(
|
||||
'--slice agents:openclaw requires --merge-from',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
async function writeAgentFiles(distDir: string): Promise<void> {
|
||||
await mkdir(path.join(distDir, 'images'), { recursive: true })
|
||||
await writeFile(
|
||||
path.join(distDir, 'images/openclaw-2026.4.12-arm64.tar.gz'),
|
||||
'arm tarball',
|
||||
)
|
||||
await writeFile(
|
||||
path.join(distDir, 'images/openclaw-2026.4.12-x64.tar.gz'),
|
||||
'x64 tarball',
|
||||
)
|
||||
}
|
||||
|
||||
async function writeJson(filePath: string, value: unknown): Promise<void> {
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`)
|
||||
}
|
||||
|
||||
async function runEmitManifest(
|
||||
args: string[],
|
||||
expectSuccess = true,
|
||||
): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
const proc = Bun.spawn(
|
||||
['bun', 'run', 'scripts/emit-manifest.ts', '--', ...args],
|
||||
{
|
||||
cwd: path.join(import.meta.dir, '..'),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
)
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
])
|
||||
|
||||
if (expectSuccess && code !== 0) {
|
||||
throw new Error(`emit-manifest failed: ${stderr || stdout}`)
|
||||
}
|
||||
|
||||
return { code, stdout, stderr }
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
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 {
|
||||
type ArtifactInputs,
|
||||
type Bundle,
|
||||
buildManifest,
|
||||
tarballKey,
|
||||
} from '../scripts/common/manifest'
|
||||
import { verifySha256 } from '../scripts/common/sha256'
|
||||
|
||||
const bundle: Bundle = {
|
||||
agents: [
|
||||
{
|
||||
name: 'openclaw',
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const inputs: ArtifactInputs = {
|
||||
agents: {
|
||||
openclaw: {
|
||||
arm64: { sha256: 'tar-arm', sizeBytes: 21 },
|
||||
x64: { sha256: 'tar-x64', sizeBytes: 22 },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('manifest helpers', () => {
|
||||
it('builds release artifact keys', () => {
|
||||
expect(tarballKey('openclaw', '2026.4.12', 'x64')).toBe(
|
||||
'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
)
|
||||
})
|
||||
|
||||
it('builds an agents-only manifest from bundle metadata and artifact inputs', () => {
|
||||
const manifest = buildManifest(
|
||||
bundle,
|
||||
inputs,
|
||||
new Date('2026-04-22T00:00:00.000Z'),
|
||||
)
|
||||
|
||||
for (const field of ['vm' + 'Version', 'vm' + 'Disk']) {
|
||||
expect(Object.hasOwn(manifest, field)).toBe(false)
|
||||
}
|
||||
expect(manifest).toMatchObject({
|
||||
schemaVersion: 2,
|
||||
updatedAt: '2026-04-22T00:00:00.000Z',
|
||||
agents: {
|
||||
openclaw: {
|
||||
image: 'ghcr.io/openclaw/openclaw',
|
||||
version: '2026.4.12',
|
||||
tarballs: {
|
||||
x64: {
|
||||
key: 'vm/images/openclaw-2026.4.12-x64.tar.gz',
|
||||
sha256: 'tar-x64',
|
||||
sizeBytes: 22,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('fails when required tarball inputs are missing', () => {
|
||||
expect(() =>
|
||||
buildManifest(bundle, {
|
||||
agents: { openclaw: { arm64: inputs.agents.openclaw.arm64 } },
|
||||
} as unknown as ArtifactInputs),
|
||||
).toThrow('missing tarball inputs for openclaw/x64')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sha256 helpers', () => {
|
||||
let dir: string | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
if (!dir) return
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
dir = null
|
||||
})
|
||||
|
||||
it('verifies matching file content and rejects mismatches', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'browseros-build-tools-'))
|
||||
const filePath = join(dir, 'artifact.txt')
|
||||
await writeFile(filePath, 'browseros\n')
|
||||
|
||||
await expect(
|
||||
verifySha256(
|
||||
filePath,
|
||||
'8e4e07174da39a48ab7aa9a1bebd3adcddff43172c0b19fcbe921cc47c599f62',
|
||||
),
|
||||
).resolves.toBeUndefined()
|
||||
await expect(verifySha256(filePath, 'bad')).rejects.toThrow(
|
||||
'sha256 mismatch',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const templatePath = path.resolve(
|
||||
import.meta.dir,
|
||||
'../template/browseros-vm.yaml',
|
||||
)
|
||||
|
||||
describe('browseros-vm Lima template', () => {
|
||||
it('uses Ubuntu minimal with Lima-managed rootless containerd and nerdctl', async () => {
|
||||
const yaml = await readFile(templatePath, 'utf8')
|
||||
|
||||
expect(yaml).toContain('ubuntu-24.04-minimal-cloudimg-arm64.img')
|
||||
expect(yaml).toContain('ubuntu-24.04-minimal-cloudimg-amd64.img')
|
||||
expect(yaml).toContain('containerd:')
|
||||
expect(yaml).toContain('system: false')
|
||||
expect(yaml).toContain('user: true')
|
||||
expect(yaml).toContain('until nerdctl info >/dev/null 2>&1')
|
||||
expect(yaml).toContain('runtime:containerd-rootless')
|
||||
expect(yaml).toContain(
|
||||
'guestSocket: "/run/user/{{.UID}}/containerd-rootless/containerd.sock"',
|
||||
)
|
||||
expect(yaml).toContain('hostSocket: "{{.Dir}}/sock/containerd.sock"')
|
||||
expect(yaml).not.toContain('sudo nerdctl')
|
||||
expect(yaml).not.toContain('/var/run/containerd/containerd.sock')
|
||||
expect(yaml).not.toContain('podman')
|
||||
expect(yaml).not.toContain('debian')
|
||||
})
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["scripts/**/*", "tests/**/*", "package.json", "bundle.json"]
|
||||
}
|
||||
@@ -9,8 +9,6 @@
|
||||
export const PATHS = {
|
||||
DEFAULT_EXECUTION_DIR: process.cwd(),
|
||||
BROWSEROS_DIR_NAME: '.browseros',
|
||||
DEV_BROWSEROS_DIR_NAME: '.browseros-dev',
|
||||
CACHE_DIR_NAME: 'cache',
|
||||
MEMORY_DIR_NAME: 'memory',
|
||||
SESSIONS_DIR_NAME: 'sessions',
|
||||
TOOL_OUTPUT_DIR_NAME: 'tool-output',
|
||||
|
||||
@@ -1,36 +1,134 @@
|
||||
{
|
||||
"resources": [
|
||||
{
|
||||
"name": "Lima limactl - macOS ARM64",
|
||||
"name": "Podman CLI - macOS ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/lima/limactl-darwin-arm64"
|
||||
"key": "third_party/podman/podman-darwin-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/lima/limactl",
|
||||
"destination": "resources/bin/third_party/podman/podman",
|
||||
"os": ["macos"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Lima limactl - macOS x64",
|
||||
"name": "Podman gvproxy - macOS ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/lima/limactl-darwin-x64"
|
||||
"key": "third_party/podman/gvproxy-darwin-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/lima/limactl",
|
||||
"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": "BrowserOS VM Lima template",
|
||||
"name": "Podman gvproxy - macOS x64",
|
||||
"source": {
|
||||
"type": "local",
|
||||
"path": "packages/build-tools/template/browseros-vm.yaml"
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/gvproxy-darwin-x64"
|
||||
},
|
||||
"destination": "resources/vm/browseros-vm.yaml",
|
||||
"destination": "resources/bin/third_party/podman/gvproxy",
|
||||
"os": ["macos"],
|
||||
"arch": ["arm64", "x64"]
|
||||
"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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@@ -40,12 +39,6 @@ func runWatch(cmd *cobra.Command, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureDevCachePresent(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureLimactlPresent(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defaultPorts := proc.DefaultLocalPorts()
|
||||
p := defaultPorts
|
||||
@@ -110,7 +103,16 @@ func runWatch(cmd *cobra.Command, args []string) error {
|
||||
var wg sync.WaitGroup
|
||||
var procs []*proc.ManagedProc
|
||||
|
||||
// Run agent codegen if generated files don't exist
|
||||
agentDir := filepath.Join(root, "apps/agent")
|
||||
if _, err := os.Stat(filepath.Join(agentDir, "generated/graphql")); os.IsNotExist(err) {
|
||||
proc.LogMsg(proc.TagBuild, "Running agent codegen...")
|
||||
if err := proc.RunBlocking(ctx, agentDir, proc.TagBuild,
|
||||
"bun", "--env-file=.env.development", "graphql-codegen", "--config", "codegen.ts"); err != nil {
|
||||
return fmt.Errorf("agent codegen failed: %w", err)
|
||||
}
|
||||
proc.LogMsg(proc.TagBuild, "agent codegen done")
|
||||
}
|
||||
|
||||
if watchManual {
|
||||
proc.LogMsg(proc.TagBuild, "Building agent (dev)...")
|
||||
@@ -187,28 +189,3 @@ func runWatch(cmd *cobra.Command, args []string) error {
|
||||
proc.LogMsg(proc.TagInfo, "All processes stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureDevCachePresent() error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manifestPath := filepath.Join(home, ".browseros-dev", "cache", "vm", "manifest.json")
|
||||
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("%s %s",
|
||||
proc.ErrorColor.Sprint("VM cache is missing."),
|
||||
proc.DimColor.Sprintf("Run %s once.", proc.BoldColor.Sprint("bun run dev:setup")),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureLimactlPresent() error {
|
||||
if _, err := exec.LookPath("limactl"); err != nil {
|
||||
return fmt.Errorf("%s %s",
|
||||
proc.ErrorColor.Sprint("Lima is not installed."),
|
||||
proc.DimColor.Sprintf("Install with %s.", proc.BoldColor.Sprint("brew install lima")),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureDevCachePresentMissingMessage(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
err := ensureDevCachePresent()
|
||||
if err == nil {
|
||||
t.Fatal("expected missing cache error")
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "VM cache is missing.") {
|
||||
t.Fatalf("expected missing cache message, got %q", msg)
|
||||
}
|
||||
if strings.Count(msg, "dev:setup") != 1 {
|
||||
t.Fatalf("expected dev:setup once, got %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, home) {
|
||||
t.Fatalf("expected cache path to be hidden, got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLimactlPresentMissingMessage(t *testing.T) {
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
|
||||
err := ensureLimactlPresent()
|
||||
if err == nil {
|
||||
t.Fatal("expected missing Lima error")
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "Lima is not installed.") {
|
||||
t.Fatalf("expected missing Lima message, got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "brew install lima") {
|
||||
t.Fatalf("expected brew install hint, got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLimactlPresentFindsPathBinary(t *testing.T) {
|
||||
binDir := t.TempDir()
|
||||
limactlPath := filepath.Join(binDir, "limactl")
|
||||
if err := os.WriteFile(limactlPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
if err := ensureLimactlPresent(); err != nil {
|
||||
t.Fatalf("expected limactl to resolve, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$(cd "$DIR/../.." && pwd)"
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
echo "[setup] Installing dependencies..."
|
||||
bun install --frozen-lockfile
|
||||
|
||||
echo "[setup] Generating agent code..."
|
||||
bun run codegen:agent
|
||||
|
||||
echo "[setup] Syncing VM cache..."
|
||||
printf '\033[31m[setup] First VM cache sync can take about 5 minutes.\033[0m\n'
|
||||
NODE_ENV=development bun run --filter @browseros/build-tools cache:sync
|
||||
|
||||
echo "[setup] Ready"
|
||||
4
packages/browseros/build/browseros.py
generated
4
packages/browseros/build/browseros.py
generated
@@ -44,10 +44,6 @@ 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
269
packages/browseros/build/cli/storage.py
generated
@@ -1,269 +0,0 @@
|
||||
#!/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()
|
||||
246
packages/browseros/build/cli/storage_test.py
generated
246
packages/browseros/build/cli/storage_test.py
generated
@@ -1,246 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the Lima R2 uploader CLI."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Tuple
|
||||
from unittest import mock
|
||||
|
||||
from build.cli import storage
|
||||
|
||||
|
||||
def _build_lima_tarball(version: str, payload: bytes) -> bytes:
|
||||
"""Return a gzipped tar containing `lima-<v>/bin/limactl` with `payload`."""
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
|
||||
info = tarfile.TarInfo(name=f"lima-{version}/bin/limactl")
|
||||
info.size = len(payload)
|
||||
info.mode = 0o755
|
||||
tar.addfile(info, io.BytesIO(payload))
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
class ParseChecksumsTest(unittest.TestCase):
|
||||
def test_parses_two_column_lines(self) -> None:
|
||||
contents = (
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa lima-1.2.3-Darwin-arm64.tar.gz\n"
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb *lima-1.2.3-Darwin-x86_64.tar.gz\n"
|
||||
)
|
||||
entries = storage._parse_checksums(contents)
|
||||
self.assertEqual(
|
||||
entries["lima-1.2.3-Darwin-arm64.tar.gz"],
|
||||
"a" * 64,
|
||||
)
|
||||
self.assertEqual(
|
||||
entries["lima-1.2.3-Darwin-x86_64.tar.gz"],
|
||||
"b" * 64,
|
||||
)
|
||||
|
||||
def test_ignores_blank_lines(self) -> None:
|
||||
contents = "\n\n" + "c" * 64 + " lima-1.0.0-Darwin-arm64.tar.gz\n\n"
|
||||
entries = storage._parse_checksums(contents)
|
||||
self.assertEqual(list(entries), ["lima-1.0.0-Darwin-arm64.tar.gz"])
|
||||
|
||||
def test_rejects_malformed_lines(self) -> None:
|
||||
with self.assertRaisesRegex(RuntimeError, "Malformed"):
|
||||
storage._parse_checksums("just-one-token\n")
|
||||
|
||||
def test_rejects_non_sha256(self) -> None:
|
||||
with self.assertRaisesRegex(RuntimeError, "Invalid sha256"):
|
||||
storage._parse_checksums("xyz foo.tar.gz\n")
|
||||
|
||||
|
||||
class NormalizeVersionTagTest(unittest.TestCase):
|
||||
def test_keeps_existing_v_prefix(self) -> None:
|
||||
self.assertEqual(storage._normalize_version_tag("v1.2.3"), "v1.2.3")
|
||||
|
||||
def test_adds_v_prefix_when_missing(self) -> None:
|
||||
self.assertEqual(storage._normalize_version_tag("1.2.3"), "v1.2.3")
|
||||
|
||||
|
||||
class ExtractLimactlTest(unittest.TestCase):
|
||||
def test_extracts_limactl_binary(self) -> None:
|
||||
payload = b"limactl-bytes-" + b"x" * 100
|
||||
tarball = _build_lima_tarball("1.2.3", payload)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
tarball_path = tmp_path / "lima.tar.gz"
|
||||
tarball_path.write_bytes(tarball)
|
||||
dest = tmp_path / "limactl"
|
||||
|
||||
storage._extract_limactl(tarball_path, dest)
|
||||
|
||||
self.assertEqual(dest.read_bytes(), payload)
|
||||
self.assertTrue(dest.stat().st_mode & 0o100, "should be executable")
|
||||
|
||||
def test_raises_when_limactl_missing(self) -> None:
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
|
||||
info = tarfile.TarInfo(name="lima-1.2.3/README")
|
||||
info.size = 5
|
||||
tar.addfile(info, io.BytesIO(b"hello"))
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
tarball_path = tmp_path / "lima.tar.gz"
|
||||
tarball_path.write_bytes(buffer.getvalue())
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "bin/limactl not found"):
|
||||
storage._extract_limactl(tarball_path, tmp_path / "out")
|
||||
|
||||
|
||||
class RollbackTest(unittest.TestCase):
|
||||
def test_rollback_deletes_all_keys(self) -> None:
|
||||
deleted: List[Tuple[str, str]] = []
|
||||
|
||||
class FakeClient:
|
||||
def delete_object(self, **kwargs: str) -> None:
|
||||
deleted.append((kwargs["Bucket"], kwargs["Key"]))
|
||||
|
||||
storage._rollback(FakeClient(), "browseros", ["a", "b", "c"])
|
||||
self.assertEqual(deleted, [("browseros", "a"), ("browseros", "b"), ("browseros", "c")])
|
||||
|
||||
def test_rollback_tolerates_delete_failures(self) -> None:
|
||||
class FakeClient:
|
||||
def delete_object(self, **kwargs: str) -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
# Should not raise — it logs a warning and moves on.
|
||||
storage._rollback(FakeClient(), "browseros", ["a"])
|
||||
|
||||
|
||||
class BuildManifestTest(unittest.TestCase):
|
||||
def test_manifest_shape(self) -> None:
|
||||
manifest = storage._build_manifest(
|
||||
"v1.2.3",
|
||||
{"arm64": "a" * 64, "x64": "b" * 64},
|
||||
{"arm64": "c" * 64, "x64": "d" * 64},
|
||||
)
|
||||
self.assertEqual(manifest["lima_version"], "v1.2.3")
|
||||
self.assertEqual(manifest["tarball_shas_upstream"]["arm64"], "a" * 64)
|
||||
self.assertEqual(manifest["r2_object_shas"]["x64"], "d" * 64)
|
||||
self.assertIn("uploaded_at", manifest)
|
||||
self.assertIn("uploaded_by", manifest)
|
||||
|
||||
|
||||
class ProcessArchTest(unittest.TestCase):
|
||||
"""Covers download + sha verify + extract + upload in one pass."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.payload = b"limactl-binary-" + b"z" * 200
|
||||
self.tarball_bytes = _build_lima_tarball("1.2.3", self.payload)
|
||||
self.expected_tarball_sha = hashlib.sha256(self.tarball_bytes).hexdigest()
|
||||
self.expected_object_sha = hashlib.sha256(self.payload).hexdigest()
|
||||
|
||||
def _fake_download(self, _url: str, dest: Path, **_kwargs: Any) -> None:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(self.tarball_bytes)
|
||||
|
||||
def test_happy_path_uploads_and_returns_shas(self) -> None:
|
||||
uploads: List[Tuple[str, str]] = []
|
||||
|
||||
def fake_upload(_client: Any, _local_path: Path, r2_key: str, bucket: str) -> bool:
|
||||
uploads.append((r2_key, bucket))
|
||||
return True
|
||||
|
||||
env = mock.Mock(r2_bucket="browseros")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
with mock.patch.object(storage, "_download", side_effect=self._fake_download), \
|
||||
mock.patch.object(storage, "upload_file_to_r2", side_effect=fake_upload):
|
||||
tarball_sha, object_sha, r2_key = storage._process_arch(
|
||||
tag="v1.2.3",
|
||||
arch=storage.LimaArch(internal="arm64", upstream="Darwin-arm64"),
|
||||
tmp_dir=tmp_path,
|
||||
checksums={
|
||||
"lima-1.2.3-Darwin-arm64.tar.gz": self.expected_tarball_sha
|
||||
},
|
||||
client=mock.Mock(),
|
||||
env=env,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
self.assertEqual(tarball_sha, self.expected_tarball_sha)
|
||||
self.assertEqual(object_sha, self.expected_object_sha)
|
||||
self.assertEqual(r2_key, "third_party/lima/limactl-darwin-arm64")
|
||||
self.assertEqual(uploads, [("third_party/lima/limactl-darwin-arm64", "browseros")])
|
||||
|
||||
def test_sha_mismatch_aborts_before_upload(self) -> None:
|
||||
uploads: List[Tuple[str, str]] = []
|
||||
|
||||
def fake_upload(_client: Any, _local_path: Path, r2_key: str, bucket: str) -> bool:
|
||||
uploads.append((r2_key, bucket))
|
||||
return True
|
||||
|
||||
env = mock.Mock(r2_bucket="browseros")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
with mock.patch.object(storage, "_download", side_effect=self._fake_download), \
|
||||
mock.patch.object(storage, "upload_file_to_r2", side_effect=fake_upload):
|
||||
with self.assertRaisesRegex(RuntimeError, "sha256 mismatch"):
|
||||
storage._process_arch(
|
||||
tag="v1.2.3",
|
||||
arch=storage.LimaArch(internal="arm64", upstream="Darwin-arm64"),
|
||||
tmp_dir=tmp_path,
|
||||
checksums={"lima-1.2.3-Darwin-arm64.tar.gz": "0" * 64},
|
||||
client=mock.Mock(),
|
||||
env=env,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
self.assertEqual(uploads, [])
|
||||
|
||||
def test_missing_checksum_entry_aborts(self) -> None:
|
||||
env = mock.Mock(r2_bucket="browseros")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
with self.assertRaisesRegex(RuntimeError, "missing from SHA256SUMS"):
|
||||
storage._process_arch(
|
||||
tag="v1.2.3",
|
||||
arch=storage.LimaArch(internal="arm64", upstream="Darwin-arm64"),
|
||||
tmp_dir=tmp_path,
|
||||
checksums={},
|
||||
client=mock.Mock(),
|
||||
env=env,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
def test_dry_run_skips_upload(self) -> None:
|
||||
uploads: List[Tuple[str, str]] = []
|
||||
|
||||
def fake_upload(*args: Any, **kwargs: Any) -> bool:
|
||||
uploads.append(("called", ""))
|
||||
return True
|
||||
|
||||
env = mock.Mock(r2_bucket="browseros")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
with mock.patch.object(storage, "_download", side_effect=self._fake_download), \
|
||||
mock.patch.object(storage, "upload_file_to_r2", side_effect=fake_upload):
|
||||
_, _, r2_key = storage._process_arch(
|
||||
tag="v1.2.3",
|
||||
arch=storage.LimaArch(internal="arm64", upstream="Darwin-arm64"),
|
||||
tmp_dir=tmp_path,
|
||||
checksums={
|
||||
"lima-1.2.3-Darwin-arm64.tar.gz": self.expected_tarball_sha
|
||||
},
|
||||
client=None,
|
||||
env=env,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
self.assertEqual(uploads, [])
|
||||
self.assertEqual(r2_key, "third_party/lima/limactl-darwin-arm64")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
13
packages/browseros/build/common/server_binaries.py
generated
13
packages/browseros/build/common/server_binaries.py
generated
@@ -30,17 +30,26 @@ MACOS_SERVER_BINARIES: Dict[str, SignSpec] = {
|
||||
),
|
||||
"bun": SignSpec("bun", "runtime", "browseros-executable-entitlements.plist"),
|
||||
"rg": SignSpec("rg", "runtime"),
|
||||
"limactl": SignSpec("limactl", "runtime"),
|
||||
"podman": SignSpec("podman", "runtime"),
|
||||
"gvproxy": SignSpec("gvproxy", "runtime"),
|
||||
"vfkit": SignSpec("vfkit", "runtime", "podman-vfkit-entitlements.plist"),
|
||||
"krunkit": SignSpec("krunkit", "runtime", "podman-krunkit-entitlements.plist"),
|
||||
"podman-mac-helper": SignSpec("podman_mac_helper", "runtime"),
|
||||
}
|
||||
|
||||
|
||||
WINDOWS_SERVER_BINARIES: List[str] = [
|
||||
"browseros_server.exe",
|
||||
"third_party/bun.exe",
|
||||
"third_party/rg.exe",
|
||||
"third_party/podman/podman.exe",
|
||||
"third_party/podman/gvproxy.exe",
|
||||
"third_party/podman/win-sshproxy.exe",
|
||||
]
|
||||
|
||||
|
||||
def macos_sign_spec_for(binary_path: Path) -> Optional[SignSpec]:
|
||||
"""Look up sign metadata by file stem (e.g., ``limactl``)."""
|
||||
"""Look up sign metadata by file stem (e.g., ``podman-mac-helper``)."""
|
||||
return MACOS_SERVER_BINARIES.get(binary_path.stem)
|
||||
|
||||
|
||||
|
||||
@@ -28,17 +28,14 @@ class MacosServerBinariesTest(unittest.TestCase):
|
||||
self.assertTrue(plist.exists(), f"{stem}: entitlements {plist} missing")
|
||||
|
||||
def test_macos_sign_spec_for_resolves_by_stem(self):
|
||||
spec = macos_sign_spec_for(Path("/x/limactl"))
|
||||
spec = macos_sign_spec_for(Path("/x/podman-mac-helper"))
|
||||
assert spec is not None
|
||||
self.assertEqual(spec.identifier_suffix, "limactl")
|
||||
self.assertEqual(spec.identifier_suffix, "podman_mac_helper")
|
||||
self.assertIsNone(macos_sign_spec_for(Path("/x/not_a_known_binary")))
|
||||
|
||||
def test_matches_lima_bundle_layout(self):
|
||||
keys = set(MACOS_SERVER_BINARIES.keys())
|
||||
self.assertIn("limactl", keys)
|
||||
forbidden = {"podman", "gvproxy", "vfkit", "krunkit", "podman-mac-helper"}
|
||||
leftover = forbidden & keys
|
||||
self.assertFalse(leftover, f"podman-era entries still present: {leftover}")
|
||||
def test_matches_podman_bundle_layout(self):
|
||||
required = {"podman", "gvproxy", "vfkit", "krunkit", "podman-mac-helper"}
|
||||
self.assertTrue(required.issubset(MACOS_SERVER_BINARIES.keys()))
|
||||
|
||||
|
||||
class WindowsServerBinariesTest(unittest.TestCase):
|
||||
@@ -61,17 +58,6 @@ class WindowsServerBinariesTest(unittest.TestCase):
|
||||
for rel, abs_path in zip(WINDOWS_SERVER_BINARIES, resolved):
|
||||
self.assertEqual(abs_path, root / rel)
|
||||
|
||||
def test_windows_has_no_stale_third_party(self):
|
||||
forbidden = {
|
||||
"third_party/podman/podman.exe",
|
||||
"third_party/podman/gvproxy.exe",
|
||||
"third_party/podman/win-sshproxy.exe",
|
||||
"third_party/bun.exe",
|
||||
"third_party/rg.exe",
|
||||
}
|
||||
leftover = forbidden & set(WINDOWS_SERVER_BINARIES)
|
||||
self.assertFalse(leftover, f"stale entries still present: {leftover}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
2
packages/browseros/build/modules/ota/common.py
generated
2
packages/browseros/build/modules/ota/common.py
generated
@@ -239,7 +239,7 @@ def create_server_bundle_zip(resources_dir: Path, output_zip: Path) -> bool:
|
||||
"""Zip an extracted ``resources/`` tree into a Sparkle payload.
|
||||
|
||||
Produces entries like ``resources/bin/browseros_server``,
|
||||
``resources/bin/third_party/lima/limactl`` — mirroring what the agent
|
||||
``resources/bin/third_party/podman/podman`` — mirroring what the agent
|
||||
build staged and what the Chromium build bakes into the installed app.
|
||||
File modes are preserved by ``ZipFile.write`` so executable bits survive.
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.hypervisor</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.virtualization</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in New Issue
Block a user