mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
Compare commits
14 Commits
fix/podman
...
fix/cache-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f504702ec | ||
|
|
c656f6236c | ||
|
|
4d660874ad | ||
|
|
819887a2c5 | ||
|
|
114d5e3a9f | ||
|
|
ecba7de221 | ||
|
|
123a13fe62 | ||
|
|
5ccdbaf87f | ||
|
|
0650f21c80 | ||
|
|
e80ec467f4 | ||
|
|
41374439c4 | ||
|
|
ad99cd6cc1 | ||
|
|
47fc9e1292 | ||
|
|
2a61dcbc58 |
157
.github/workflows/build-agent.yml
vendored
Normal file
157
.github/workflows/build-agent.yml
vendored
Normal file
@@ -0,0 +1,157 @@
|
||||
name: build-agent
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
agent:
|
||||
description: "Agent name from bundle.json"
|
||||
required: true
|
||||
type: string
|
||||
default: openclaw
|
||||
publish:
|
||||
description: "Upload to R2 and merge manifest slice"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
pull_request:
|
||||
paths:
|
||||
- "packages/browseros-agent/packages/build-tools/**"
|
||||
- ".github/workflows/build-agent.yml"
|
||||
|
||||
env:
|
||||
BUN_VERSION: "1.3.6"
|
||||
PKG_DIR: packages/browseros-agent/packages/build-tools
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun install --frozen-lockfile
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun run --filter @browseros/build-tools typecheck
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun run --filter @browseros/build-tools test
|
||||
|
||||
build:
|
||||
needs: check
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
- name: Install podman
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y podman
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Build tarball
|
||||
working-directory: ${{ env.PKG_DIR }}
|
||||
env:
|
||||
AGENT: ${{ inputs.agent || 'openclaw' }}
|
||||
OUT: ${{ github.workspace }}/dist/images
|
||||
run: bun run build:tarball -- --agent "$AGENT" --arch "${{ matrix.arch }}" --output-dir "$OUT"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tarball-${{ inputs.agent || 'openclaw' }}-${{ matrix.arch }}
|
||||
path: dist/images/
|
||||
retention-days: 7
|
||||
|
||||
smoke:
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tarball-${{ inputs.agent || 'openclaw' }}-arm64
|
||||
path: dist/images
|
||||
- name: Install podman
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y podman
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Smoke test tarball
|
||||
working-directory: ${{ env.PKG_DIR }}
|
||||
env:
|
||||
AGENT: ${{ inputs.agent || 'openclaw' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tarball="$(find "$GITHUB_WORKSPACE/dist/images" -name "${AGENT}-*-arm64.tar.gz" -print -quit)"
|
||||
if [ -z "$tarball" ]; then
|
||||
echo "missing arm64 tarball artifact for ${AGENT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
bun run smoke:tarball -- --agent "$AGENT" --arch arm64 --tarball "$tarball"
|
||||
|
||||
publish:
|
||||
needs: [build, smoke]
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish == true }}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: release
|
||||
concurrency:
|
||||
group: r2-manifest-publish
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: tarball-*
|
||||
path: dist/images
|
||||
merge-multiple: true
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Upload tarballs to R2
|
||||
working-directory: ${{ env.PKG_DIR }}
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for file in "$GITHUB_WORKSPACE"/dist/images/*.tar.gz; do
|
||||
base="$(basename "$file")"
|
||||
bun run upload -- --file "$file" --key "vm/images/$base" --content-type "application/gzip" --sidecar-sha
|
||||
done
|
||||
- name: Merge agent slice into manifest
|
||||
working-directory: ${{ env.PKG_DIR }}
|
||||
env:
|
||||
AGENT: ${{ inputs.agent || 'openclaw' }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist/images
|
||||
cp -R "$GITHUB_WORKSPACE"/dist/images/* dist/images/
|
||||
bun run download -- --key vm/manifest.json --out dist/baseline-manifest.json
|
||||
bun run emit-manifest -- \
|
||||
--slice "agents:${AGENT}" \
|
||||
--dist-dir dist \
|
||||
--merge-from dist/baseline-manifest.json \
|
||||
--out dist/manifest.json
|
||||
bun run upload -- --file dist/manifest.json --key vm/manifest.json --content-type "application/json"
|
||||
1
packages/browseros-agent/.gitignore
vendored
1
packages/browseros-agent/.gitignore
vendored
@@ -14,6 +14,7 @@ lerna-debug.log*
|
||||
# Ignore all .env files except .env.example
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
!**/.env.sample
|
||||
!**/.env.production.example
|
||||
|
||||
|
||||
|
||||
@@ -218,3 +218,9 @@ This uses the same element resolution as the server's MCP tools — no coordinat
|
||||
The `<target>` argument can be:
|
||||
- An **index** from the `targets` output (e.g., `3`)
|
||||
- A **URL substring** (e.g., `sidepanel`, `newtab`, `chrome-extension://`)
|
||||
|
||||
## Release gating — bundled-VM runtime migration (2026-Q2)
|
||||
|
||||
Between the Lima server-prod-resources cutover (WS3) and the ContainerRuntime migration (WS6) landing, `resources/bin/third_party/` ships `limactl` instead of `podman`. The current OpenClaw runtime (`apps/server/src/api/services/openclaw/podman-runtime.ts`, `container-runtime.ts`) still invokes `podman`; it will fail to find the binary on builds cut from `dev`.
|
||||
|
||||
Do **not** cut a release branch off `dev` during this window. Track WS6 progress before any release cut. See `specs/bundled-vm-runtime-spec.md` + `specs/workstreams.md` for context.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
buildChatHistoryFromTurns,
|
||||
chatWithAgent,
|
||||
type OpenClawStreamEvent,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
@@ -187,6 +188,7 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
||||
|
||||
const send = async (text: string) => {
|
||||
if (!text.trim() || streaming) return
|
||||
const history = buildChatHistoryFromTurns(turns)
|
||||
|
||||
const turn: AgentConversationTurn = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -207,6 +209,7 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
||||
agentId,
|
||||
text.trim(),
|
||||
sessionKeyRef.current,
|
||||
history,
|
||||
abortController.signal,
|
||||
)
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -20,7 +20,11 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { consumeSSEStream } from '@/lib/sse'
|
||||
import { chatWithAgent, type OpenClawStreamEvent } from './useOpenClaw'
|
||||
import {
|
||||
buildChatHistoryFromTurns,
|
||||
chatWithAgent,
|
||||
type OpenClawStreamEvent,
|
||||
} from './useOpenClaw'
|
||||
|
||||
interface ToolEntry {
|
||||
id: string
|
||||
@@ -204,6 +208,7 @@ export const AgentChat: FC<AgentChatProps> = ({
|
||||
const handleSend = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || streaming) return
|
||||
const history = buildChatHistoryFromTurns(turns)
|
||||
|
||||
const turn: ChatTurn = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -225,6 +230,7 @@ export const AgentChat: FC<AgentChatProps> = ({
|
||||
agentId,
|
||||
text,
|
||||
sessionKeyRef.current,
|
||||
history,
|
||||
abortController.signal,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
@@ -37,12 +39,22 @@ import { AgentTerminal } from './AgentTerminal'
|
||||
import { getOpenClawSupportedProviders } from './openclaw-supported-providers'
|
||||
import {
|
||||
type AgentEntry,
|
||||
type GatewayLifecycleAction,
|
||||
type OpenClawStatus,
|
||||
useOpenClawAgents,
|
||||
useOpenClawMutations,
|
||||
useOpenClawStatus,
|
||||
usePodmanOverrides,
|
||||
} from './useOpenClaw'
|
||||
|
||||
const LIFECYCLE_BANNER_COPY: Record<GatewayLifecycleAction, string> = {
|
||||
setup: 'Setting up OpenClaw...',
|
||||
start: 'Starting gateway...',
|
||||
stop: 'Stopping gateway...',
|
||||
restart: 'Restarting gateway...',
|
||||
reconnect: 'Restoring gateway connection...',
|
||||
}
|
||||
|
||||
const CONTROL_PLANE_COPY: Record<
|
||||
OpenClawStatus['controlPlaneStatus'],
|
||||
{
|
||||
@@ -226,6 +238,122 @@ const ProviderSelector: FC<ProviderSelectorProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
const PodmanOverridesCard: FC = () => {
|
||||
const { overrides, loading, saving, error, saveOverrides, clearOverrides } =
|
||||
usePodmanOverrides()
|
||||
|
||||
const [value, setValue] = useState('')
|
||||
const [touched, setTouched] = useState(false)
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!touched && overrides) setValue(overrides.podmanPath ?? '')
|
||||
}, [overrides, touched])
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return
|
||||
setLocalError(null)
|
||||
try {
|
||||
await saveOverrides(trimmed)
|
||||
setTouched(false)
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = async () => {
|
||||
setLocalError(null)
|
||||
try {
|
||||
await clearOverrides()
|
||||
setValue('')
|
||||
setTouched(false)
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const hasOverride = !!overrides?.podmanPath
|
||||
const effective = overrides?.effectivePodmanPath ?? null
|
||||
const inlineErrorMessage = localError ?? error?.message ?? null
|
||||
|
||||
const body = (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="podman-path" className="font-medium text-sm">
|
||||
Podman binary path
|
||||
</label>
|
||||
<Input
|
||||
id="podman-path"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setTouched(true)
|
||||
setValue(event.target.value)
|
||||
}}
|
||||
placeholder="/opt/homebrew/bin/podman"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Install Podman yourself (e.g. <code>brew install podman</code>) and
|
||||
paste the absolute path to the binary. Restart the gateway after
|
||||
saving.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{effective && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Currently using: <code className="break-all">{effective}</code>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{inlineErrorMessage && (
|
||||
<p className="text-destructive text-xs">{inlineErrorMessage}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading || !value.trim()}
|
||||
>
|
||||
{saving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleClear}
|
||||
disabled={saving || loading || !hasOverride}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer py-3"
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{collapsed ? (
|
||||
<ChevronRight className="size-4" />
|
||||
) : (
|
||||
<ChevronDown className="size-4" />
|
||||
)}
|
||||
Advanced: Podman binary path
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{!collapsed && <CardContent className="pt-0">{body}</CardContent>}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export const AgentsPage: FC = () => {
|
||||
const {
|
||||
status,
|
||||
@@ -253,6 +381,7 @@ export const AgentsPage: FC = () => {
|
||||
creating,
|
||||
deleting,
|
||||
reconnecting,
|
||||
pendingGatewayAction,
|
||||
} = useOpenClawMutations()
|
||||
|
||||
const [setupOpen, setSetupOpen] = useState(false)
|
||||
@@ -289,8 +418,13 @@ export const AgentsPage: FC = () => {
|
||||
setNewName((current) => current || 'agent')
|
||||
}, [createOpen])
|
||||
|
||||
const inlineError =
|
||||
error ?? statusError?.message ?? agentsError?.message ?? null
|
||||
const lifecyclePending = pendingGatewayAction !== null
|
||||
const inlineError = lifecyclePending
|
||||
? null
|
||||
: (error ?? statusError?.message ?? agentsError?.message ?? null)
|
||||
const lifecycleBanner = pendingGatewayAction
|
||||
? LIFECYCLE_BANNER_COPY[pendingGatewayAction]
|
||||
: null
|
||||
|
||||
const gatewayUiState = useMemo(() => {
|
||||
if (!status) {
|
||||
@@ -319,6 +453,10 @@ export const AgentsPage: FC = () => {
|
||||
}
|
||||
}, [status])
|
||||
|
||||
const canManageAgents = gatewayUiState.canManageAgents && !lifecyclePending
|
||||
const showControlPlaneDegraded =
|
||||
!lifecyclePending && gatewayUiState.controlPlaneDegraded
|
||||
|
||||
const recoveryDetail = status ? getRecoveryDetail(status) : null
|
||||
const controlPlaneCopy = status
|
||||
? getControlPlaneCopy(status.controlPlaneStatus)
|
||||
@@ -482,7 +620,7 @@ export const AgentsPage: FC = () => {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={!gatewayUiState.canManageAgents}
|
||||
disabled={!canManageAgents}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
New Agent
|
||||
@@ -493,6 +631,13 @@ export const AgentsPage: FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lifecycleBanner && (
|
||||
<Alert>
|
||||
<Loader2 className="animate-spin" />
|
||||
<AlertTitle>{lifecycleBanner}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{inlineError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle />
|
||||
@@ -512,7 +657,7 @@ export const AgentsPage: FC = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status && gatewayUiState.controlPlaneDegraded && (
|
||||
{status && showControlPlaneDegraded && (
|
||||
<Alert
|
||||
variant={
|
||||
status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'
|
||||
@@ -633,7 +778,7 @@ export const AgentsPage: FC = () => {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={!gatewayUiState.canManageAgents}
|
||||
disabled={!canManageAgents}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
Create Agent
|
||||
@@ -662,7 +807,7 @@ export const AgentsPage: FC = () => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setChatAgent(agent)}
|
||||
disabled={!gatewayUiState.canManageAgents}
|
||||
disabled={!canManageAgents}
|
||||
>
|
||||
<MessageSquare className="mr-1 size-4" />
|
||||
Chat
|
||||
@@ -672,7 +817,7 @@ export const AgentsPage: FC = () => {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(agent.agentId)}
|
||||
disabled={!gatewayUiState.canManageAgents || deleting}
|
||||
disabled={!canManageAgents || deleting}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
@@ -685,6 +830,8 @@ export const AgentsPage: FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PodmanOverridesCard />
|
||||
|
||||
<Dialog open={setupOpen} onOpenChange={setSetupOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -754,7 +901,7 @@ export const AgentsPage: FC = () => {
|
||||
disabled={
|
||||
!newName.trim() ||
|
||||
creating ||
|
||||
!gatewayUiState.canManageAgents ||
|
||||
!canManageAgents ||
|
||||
compatibleProviders.length === 0
|
||||
}
|
||||
className="w-full"
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
|
||||
const OPENCLAW_SUPPORTED_PROVIDER_TYPES: ProviderType[] = [
|
||||
'openrouter',
|
||||
'openai',
|
||||
'openai-compatible',
|
||||
'anthropic',
|
||||
'moonshot',
|
||||
]
|
||||
|
||||
@@ -59,8 +59,21 @@ 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'
|
||||
| 'stop'
|
||||
| 'restart'
|
||||
| 'reconnect'
|
||||
|
||||
async function clawFetch<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
@@ -218,6 +231,13 @@ export function useOpenClawMutations() {
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
let pendingGatewayAction: GatewayLifecycleAction | null = null
|
||||
if (setupMutation.isPending) pendingGatewayAction = 'setup'
|
||||
else if (restartMutation.isPending) pendingGatewayAction = 'restart'
|
||||
else if (stopMutation.isPending) pendingGatewayAction = 'stop'
|
||||
else if (startMutation.isPending) pendingGatewayAction = 'start'
|
||||
else if (reconnectMutation.isPending) pendingGatewayAction = 'reconnect'
|
||||
|
||||
return {
|
||||
setupOpenClaw: setupMutation.mutateAsync,
|
||||
createAgent: createMutation.mutateAsync,
|
||||
@@ -238,6 +258,51 @@ export function useOpenClawMutations() {
|
||||
creating: createMutation.isPending,
|
||||
deleting: deleteMutation.isPending,
|
||||
reconnecting: reconnectMutation.isPending,
|
||||
pendingGatewayAction,
|
||||
}
|
||||
}
|
||||
|
||||
export function usePodmanOverrides() {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const query = useQuery<PodmanOverrides, Error>({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.podmanOverrides, baseUrl],
|
||||
queryFn: () =>
|
||||
clawFetch<PodmanOverrides>(baseUrl as string, '/podman-overrides'),
|
||||
enabled: !!baseUrl && !urlLoading,
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (podmanPath: string | null) =>
|
||||
clawFetch<PodmanOverrides>(baseUrl as string, '/podman-overrides', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ podmanPath }),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.podmanOverrides],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.status],
|
||||
}),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
overrides: query.data ?? null,
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: (query.error ?? urlError) as Error | null,
|
||||
saving: saveMutation.isPending,
|
||||
saveOverrides: (podmanPath: string) => saveMutation.mutateAsync(podmanPath),
|
||||
clearOverrides: () => saveMutation.mutateAsync(null),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,17 +319,60 @@ export interface OpenClawStreamEvent {
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface OpenClawChatHistoryMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
interface ChatHistoryTurnLike {
|
||||
userText: string
|
||||
parts: Array<{ kind: string; text?: string }>
|
||||
}
|
||||
|
||||
export function buildChatHistoryFromTurns(
|
||||
turns: ChatHistoryTurnLike[],
|
||||
): OpenClawChatHistoryMessage[] {
|
||||
const messages: OpenClawChatHistoryMessage[] = []
|
||||
|
||||
for (const turn of turns) {
|
||||
const userText = turn.userText.trim()
|
||||
if (userText) {
|
||||
messages.push({ role: 'user', content: userText })
|
||||
}
|
||||
|
||||
const assistantText = turn.parts
|
||||
.filter(
|
||||
(
|
||||
part,
|
||||
): part is {
|
||||
kind: 'text'
|
||||
text: string
|
||||
} => part.kind === 'text' && typeof part.text === 'string',
|
||||
)
|
||||
.map((part) => part.text.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
|
||||
if (assistantText) {
|
||||
messages.push({ role: 'assistant', content: assistantText })
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
export async function chatWithAgent(
|
||||
agentId: string,
|
||||
message: string,
|
||||
sessionKey?: string,
|
||||
history: OpenClawChatHistoryMessage[] = [],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
return fetch(`${baseUrl}/claw/agents/${agentId}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, sessionKey }),
|
||||
body: JSON.stringify({ message, sessionKey, history }),
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
services:
|
||||
openclaw-gateway:
|
||||
# Pin away from latest because newer OpenClaw releases regress OpenRouter chat streams.
|
||||
image: ${OPENCLAW_IMAGE:-ghcr.io/openclaw/openclaw:2026.4.12}
|
||||
ports:
|
||||
- "127.0.0.1:${OPENCLAW_GATEWAY_PORT:-18789}:18789"
|
||||
env_file:
|
||||
- ./.openclaw/.env
|
||||
environment:
|
||||
- HOME=/home/node
|
||||
- OPENCLAW_HOME=/home/node
|
||||
- OPENCLAW_STATE_DIR=/home/node/.openclaw
|
||||
- OPENCLAW_GATEWAY_TOKEN=${OPENCLAW_GATEWAY_TOKEN:-}
|
||||
- OPENCLAW_NO_RESPAWN=1
|
||||
- NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache
|
||||
- NODE_ENV=production
|
||||
- TZ=${TZ}
|
||||
volumes:
|
||||
- ${OPENCLAW_HOST_HOME}:/home/node
|
||||
extra_hosts:
|
||||
- "host.containers.internal:host-gateway"
|
||||
command:
|
||||
- node
|
||||
- dist/index.js
|
||||
- gateway
|
||||
- --bind
|
||||
- lan
|
||||
- --port
|
||||
- "18789"
|
||||
- --allow-unconfigured
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://127.0.0.1:18789/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
@@ -10,6 +10,7 @@ import type { Browser } from '../../browser/browser'
|
||||
import { logger } from '../../lib/logger'
|
||||
import { metrics } from '../../lib/metrics'
|
||||
import { Sentry } from '../../lib/sentry'
|
||||
import { getMonitoringService } from '../../monitoring/service'
|
||||
import type { ToolRegistry } from '../../tools/tool-registry'
|
||||
import type { GlobalAclPolicyService } from '../services/acl/global-acl-policy'
|
||||
import { resolveAclPolicyForMcpRequest } from '../services/acl/resolve-acl-policy'
|
||||
@@ -39,16 +40,35 @@ export function createMcpRoutes(deps: McpRouteDeps) {
|
||||
|
||||
app.post('/', async (c) => {
|
||||
const scopeId = c.req.header('X-BrowserOS-Scope-Id') || 'ephemeral'
|
||||
const monitoringService = getMonitoringService()
|
||||
const explicitAgentId =
|
||||
c.req.query('agentId') ??
|
||||
c.req.header('X-BrowserOS-Agent-Id') ??
|
||||
undefined
|
||||
const activeSession = explicitAgentId
|
||||
? {
|
||||
agentId: explicitAgentId,
|
||||
monitoringSessionId:
|
||||
monitoringService.getActiveSessionId(explicitAgentId),
|
||||
}
|
||||
: monitoringService.getSingleActiveSession()
|
||||
const agentId = activeSession?.agentId
|
||||
metrics.log('mcp.request', { scopeId })
|
||||
const aclRules = await resolveAclPolicyForMcpRequest({
|
||||
policyService: deps.policyService,
|
||||
})
|
||||
const monitoringSessionId = activeSession?.monitoringSessionId
|
||||
const observer =
|
||||
monitoringSessionId && agentId
|
||||
? monitoringService.createObserver(monitoringSessionId, agentId)
|
||||
: undefined
|
||||
|
||||
// Per-request server + transport: no shared state, no race conditions,
|
||||
// no ID collisions. Required by MCP SDK 1.26.0+ security fix (GHSA-345p-7cg4-v4c7).
|
||||
const mcpServer = createMcpServer({
|
||||
...deps,
|
||||
aclRules,
|
||||
observer,
|
||||
})
|
||||
const transport = new StreamableHTTPTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
@@ -62,6 +82,9 @@ export function createMcpRoutes(deps: McpRouteDeps) {
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag('route', 'mcp')
|
||||
scope.setTag('scopeId', scopeId)
|
||||
if (agentId) {
|
||||
scope.setTag('agentId', agentId)
|
||||
}
|
||||
Sentry.captureException(error)
|
||||
})
|
||||
logger.error('Error handling MCP request', {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Hono } from 'hono'
|
||||
import { getMonitoringService } from '../../monitoring/service'
|
||||
import { isValidMonitoringRunId } from '../../monitoring/storage'
|
||||
|
||||
export function createMonitoringRoutes() {
|
||||
return new Hono()
|
||||
.get('/runs', async (c) => {
|
||||
const limitParam = c.req.query('limit')
|
||||
const parsedLimit = limitParam ? Number.parseInt(limitParam, 10) : 50
|
||||
const limit =
|
||||
Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 50
|
||||
|
||||
const runs = await getMonitoringService().listRuns(limit)
|
||||
return c.json({ runs })
|
||||
})
|
||||
.get('/runs/:id', async (c) => {
|
||||
const runId = c.req.param('id')
|
||||
if (!isValidMonitoringRunId(runId)) {
|
||||
return c.json({ error: 'Invalid monitoring run id' }, 400)
|
||||
}
|
||||
const envelope = await getMonitoringService().getRunEnvelope(runId)
|
||||
|
||||
if (!envelope) {
|
||||
return c.json({ error: 'Monitoring run not found' }, 404)
|
||||
}
|
||||
|
||||
return c.json({ run: envelope })
|
||||
})
|
||||
.post('/debug/runs', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
agentId?: string
|
||||
sessionKey?: string
|
||||
originalPrompt?: string
|
||||
chatHistory?: Array<{ role?: 'user' | 'assistant'; content?: string }>
|
||||
}>()
|
||||
|
||||
if (!body.agentId?.trim()) {
|
||||
return c.json({ error: 'agentId is required' }, 400)
|
||||
}
|
||||
if (!body.sessionKey?.trim()) {
|
||||
return c.json({ error: 'sessionKey is required' }, 400)
|
||||
}
|
||||
if (!body.originalPrompt?.trim()) {
|
||||
return c.json({ error: 'originalPrompt is required' }, 400)
|
||||
}
|
||||
|
||||
const chatHistory = Array.isArray(body.chatHistory)
|
||||
? body.chatHistory
|
||||
.filter(
|
||||
(turn): turn is { role: 'user' | 'assistant'; content: string } =>
|
||||
(turn.role === 'user' || turn.role === 'assistant') &&
|
||||
typeof turn.content === 'string',
|
||||
)
|
||||
.map((turn) => ({
|
||||
role: turn.role,
|
||||
content: turn.content,
|
||||
}))
|
||||
: []
|
||||
|
||||
const session = await getMonitoringService().startSession({
|
||||
agentId: body.agentId.trim(),
|
||||
sessionKey: body.sessionKey.trim(),
|
||||
originalPrompt: body.originalPrompt.trim(),
|
||||
chatHistory,
|
||||
source: 'debug',
|
||||
})
|
||||
|
||||
return c.json({ session }, 201)
|
||||
})
|
||||
.post('/debug/runs/:id/finalize', async (c) => {
|
||||
const runId = c.req.param('id')
|
||||
if (!isValidMonitoringRunId(runId)) {
|
||||
return c.json({ error: 'Invalid monitoring run id' }, 400)
|
||||
}
|
||||
const body = await c.req.json<{
|
||||
agentId?: string
|
||||
sessionKey?: string
|
||||
status?: 'completed' | 'failed' | 'aborted' | 'incomplete'
|
||||
finalAssistantMessage?: string
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
if (!body.agentId?.trim()) {
|
||||
return c.json({ error: 'agentId is required' }, 400)
|
||||
}
|
||||
if (!body.sessionKey?.trim()) {
|
||||
return c.json({ error: 'sessionKey is required' }, 400)
|
||||
}
|
||||
if (
|
||||
body.status !== 'completed' &&
|
||||
body.status !== 'failed' &&
|
||||
body.status !== 'aborted' &&
|
||||
body.status !== 'incomplete'
|
||||
) {
|
||||
return c.json({ error: 'status is invalid' }, 400)
|
||||
}
|
||||
|
||||
const envelope = await getMonitoringService().finalizeSession({
|
||||
monitoringSessionId: runId,
|
||||
agentId: body.agentId.trim(),
|
||||
sessionKey: body.sessionKey.trim(),
|
||||
status: body.status,
|
||||
finalAssistantMessage: body.finalAssistantMessage,
|
||||
error: body.error,
|
||||
})
|
||||
|
||||
if (!envelope) {
|
||||
return c.json({ error: 'Monitoring run not found' }, 404)
|
||||
}
|
||||
|
||||
return c.json({ run: envelope })
|
||||
})
|
||||
}
|
||||
@@ -7,10 +7,13 @@
|
||||
* Thin layer delegating to OpenClawService.
|
||||
*/
|
||||
|
||||
import { OPENCLAW_GATEWAY_PORT } from '@browseros/shared/constants/openclaw'
|
||||
import { accessSync, existsSync, constants as fsConstants } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { Hono } from 'hono'
|
||||
import { stream } from 'hono/streaming'
|
||||
import { logger } from '../../lib/logger'
|
||||
import { getMonitoringService } from '../../monitoring/service'
|
||||
import type { MonitoringChatTurn } from '../../monitoring/types'
|
||||
import {
|
||||
OpenClawAgentAlreadyExistsError,
|
||||
OpenClawAgentNotFoundError,
|
||||
@@ -27,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) => {
|
||||
@@ -58,7 +82,7 @@ export function createOpenClawRoutes() {
|
||||
return c.json(
|
||||
{
|
||||
status: 'running',
|
||||
port: OPENCLAW_GATEWAY_PORT,
|
||||
port: getOpenClawService().getPort(),
|
||||
agents: agents.map((a) => ({
|
||||
agentId: a.agentId,
|
||||
name: a.name,
|
||||
@@ -205,6 +229,7 @@ export function createOpenClawRoutes() {
|
||||
const body = await c.req.json<{
|
||||
message: string
|
||||
sessionKey?: string
|
||||
history?: MonitoringChatTurn[]
|
||||
}>()
|
||||
|
||||
if (!body.message?.trim()) {
|
||||
@@ -212,12 +237,37 @@ export function createOpenClawRoutes() {
|
||||
}
|
||||
|
||||
const sessionKey = body.sessionKey ?? crypto.randomUUID()
|
||||
const history = Array.isArray(body.history)
|
||||
? body.history.filter((entry): entry is MonitoringChatTurn =>
|
||||
Boolean(
|
||||
entry &&
|
||||
(entry.role === 'user' || entry.role === 'assistant') &&
|
||||
typeof entry.content === 'string',
|
||||
),
|
||||
)
|
||||
: []
|
||||
if (getMonitoringService().getActiveSessionId(id)) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'A monitored chat session is already active for this agent. Wait for it to finish before starting another.',
|
||||
},
|
||||
409,
|
||||
)
|
||||
}
|
||||
const monitoringContext = await getMonitoringService().startSession({
|
||||
agentId: id,
|
||||
sessionKey,
|
||||
originalPrompt: body.message.trim(),
|
||||
chatHistory: history,
|
||||
})
|
||||
|
||||
try {
|
||||
const eventStream = await getOpenClawService().chatStream(
|
||||
id,
|
||||
sessionKey,
|
||||
body.message,
|
||||
history,
|
||||
)
|
||||
|
||||
c.header('Content-Type', 'text/event-stream')
|
||||
@@ -227,20 +277,65 @@ export function createOpenClawRoutes() {
|
||||
return stream(c, async (s) => {
|
||||
const reader = eventStream.getReader()
|
||||
const encoder = new TextEncoder()
|
||||
let finalAssistantMessage: string | undefined
|
||||
let status: 'completed' | 'failed' | 'aborted' | 'incomplete' =
|
||||
'incomplete'
|
||||
let finalError: string | undefined
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (
|
||||
value.type === 'done' &&
|
||||
typeof value.data.text === 'string' &&
|
||||
value.data.text.trim()
|
||||
) {
|
||||
finalAssistantMessage = value.data.text
|
||||
status = 'completed'
|
||||
}
|
||||
if (value.type === 'error') {
|
||||
finalError =
|
||||
(typeof value.data.message === 'string'
|
||||
? value.data.message
|
||||
: typeof value.data.error === 'string'
|
||||
? value.data.error
|
||||
: undefined) ?? 'Unknown chat stream error'
|
||||
status = 'failed'
|
||||
}
|
||||
await s.write(
|
||||
encoder.encode(`data: ${JSON.stringify(value)}\n\n`),
|
||||
)
|
||||
}
|
||||
await s.write(encoder.encode('data: [DONE]\n\n'))
|
||||
} catch (error) {
|
||||
if (c.req.raw.signal.aborted) {
|
||||
status = 'aborted'
|
||||
} else {
|
||||
status = 'failed'
|
||||
finalError =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
await reader.cancel()
|
||||
await getMonitoringService().finalizeSession({
|
||||
monitoringSessionId: monitoringContext.monitoringSessionId,
|
||||
agentId: id,
|
||||
sessionKey,
|
||||
status,
|
||||
finalAssistantMessage,
|
||||
error: finalError,
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
await getMonitoringService().finalizeSession({
|
||||
monitoringSessionId: monitoringContext.monitoringSessionId,
|
||||
agentId: id,
|
||||
sessionKey,
|
||||
status: c.req.raw.signal.aborted ? 'aborted' : 'failed',
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
if (isUnsupportedOpenClawProviderError(err)) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
@@ -288,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { createHealthRoute } from './routes/health'
|
||||
import { createKlavisRoutes } from './routes/klavis'
|
||||
import { createMcpRoutes } from './routes/mcp'
|
||||
import { createMemoryRoutes } from './routes/memory'
|
||||
import { createMonitoringRoutes } from './routes/monitoring'
|
||||
import { createOAuthRoutes } from './routes/oauth'
|
||||
import { createOpenClawRoutes } from './routes/openclaw'
|
||||
import { createProviderRoutes } from './routes/provider'
|
||||
@@ -121,6 +122,10 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
.use('/*', requireTrustedAppOrigin())
|
||||
.route('/', createAclRoutes({ policyService: aclPolicyService }))
|
||||
|
||||
const monitoringRoutes = new Hono<Env>()
|
||||
.use('/*', requireTrustedAppOrigin())
|
||||
.route('/', createMonitoringRoutes())
|
||||
|
||||
const app = new Hono<Env>()
|
||||
.use('/*', cors(defaultCorsConfig))
|
||||
.route('/health', createHealthRoute({ browser }))
|
||||
@@ -143,6 +148,7 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
.route('/soul', createSoulRoutes())
|
||||
.route('/memory', createMemoryRoutes())
|
||||
.route('/skills', createSkillsRoutes())
|
||||
.route('/monitoring', monitoringRoutes)
|
||||
.route('/acl-rules', aclRoutes)
|
||||
.route('/test-provider', createProviderRoutes({ browserosId }))
|
||||
.route('/refine-prompt', createRefinePromptRoutes({ browserosId }))
|
||||
|
||||
@@ -20,6 +20,7 @@ import { KlavisClient } from '../../../lib/clients/klavis/klavis-client'
|
||||
import { OAUTH_MCP_SERVERS } from '../../../lib/clients/klavis/oauth-mcp-servers'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import { metrics } from '../../../lib/metrics'
|
||||
import type { ToolExecutionObserver } from '../../../monitoring/observer'
|
||||
import { klavisStrataCache } from './strata-cache'
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
|
||||
@@ -237,6 +238,7 @@ export function buildKlavisToolSet(handle: KlavisProxyHandle): ToolSet {
|
||||
export function registerKlavisTools(
|
||||
mcpServer: McpServer,
|
||||
handle: KlavisProxyHandle,
|
||||
observer?: ToolExecutionObserver,
|
||||
): void {
|
||||
mcpServer.registerTool(
|
||||
'connector_mcp_servers',
|
||||
@@ -247,9 +249,16 @@ export function registerKlavisTools(
|
||||
},
|
||||
async (args: Record<string, unknown>) => {
|
||||
const startTime = performance.now()
|
||||
const toolCallId = crypto.randomUUID()
|
||||
const server_name = args.server_name as string
|
||||
|
||||
try {
|
||||
await observer?.onToolStart({
|
||||
toolCallId,
|
||||
toolName: 'connector_mcp_servers',
|
||||
source: 'klavis-tool',
|
||||
args,
|
||||
})
|
||||
const klavisClient = new KlavisClient()
|
||||
const integrations = await klavisClient.getUserIntegrations(
|
||||
handle.browserosId,
|
||||
@@ -266,6 +275,14 @@ export function registerKlavisTools(
|
||||
success: true,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
output: {
|
||||
connected: true,
|
||||
server_name,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -294,6 +311,15 @@ export function registerKlavisTools(
|
||||
success: true,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
output: {
|
||||
connected: false,
|
||||
server_name,
|
||||
authUrl,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -320,6 +346,11 @@ export function registerKlavisTools(
|
||||
error_message: errorText,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
error: errorText,
|
||||
})
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: errorText }],
|
||||
isError: true,
|
||||
@@ -339,7 +370,14 @@ export function registerKlavisTools(
|
||||
},
|
||||
async (args: Record<string, unknown>) => {
|
||||
const startTime = performance.now()
|
||||
const toolCallId = crypto.randomUUID()
|
||||
try {
|
||||
await observer?.onToolStart({
|
||||
toolCallId,
|
||||
toolName: tool.name,
|
||||
source: 'klavis-tool',
|
||||
args,
|
||||
})
|
||||
const result = await handle.callTool(tool.name, args)
|
||||
|
||||
metrics.log('tool_executed', {
|
||||
@@ -349,6 +387,12 @@ export function registerKlavisTools(
|
||||
success: !result.isError,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
output: result,
|
||||
error: result.isError ? 'Tool returned isError=true' : undefined,
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
const errorText =
|
||||
@@ -362,6 +406,11 @@ export function registerKlavisTools(
|
||||
error_message: errorText,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
error: errorText,
|
||||
})
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: errorText }],
|
||||
isError: true,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { AclRule } from '@browseros/shared/types/acl'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import type { Browser } from '../../../browser/browser'
|
||||
import type { ToolExecutionObserver } from '../../../monitoring/observer'
|
||||
import type { ToolRegistry } from '../../../tools/tool-registry'
|
||||
import {
|
||||
type KlavisProxyRef,
|
||||
@@ -24,6 +25,7 @@ export interface McpServiceDeps {
|
||||
resourcesDir: string
|
||||
aclRules?: AclRule[]
|
||||
klavisRef?: KlavisProxyRef
|
||||
observer?: ToolExecutionObserver
|
||||
}
|
||||
|
||||
export function createMcpServer(deps: McpServiceDeps): McpServer {
|
||||
@@ -48,11 +50,12 @@ export function createMcpServer(deps: McpServiceDeps): McpServer {
|
||||
resourcesDir: deps.resourcesDir,
|
||||
},
|
||||
aclRules: deps.aclRules,
|
||||
observer: deps.observer,
|
||||
})
|
||||
|
||||
// Register Klavis proxy tools (if connected via background init)
|
||||
if (deps.klavisRef?.handle) {
|
||||
registerKlavisTools(server, deps.klavisRef.handle)
|
||||
registerKlavisTools(server, deps.klavisRef.handle, deps.observer)
|
||||
}
|
||||
|
||||
return server
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import { metrics } from '../../../lib/metrics'
|
||||
import type { ToolExecutionObserver } from '../../../monitoring/observer'
|
||||
import { executeTool, type ToolContext } from '../../../tools/framework'
|
||||
import type { ToolRegistry } from '../../../tools/tool-registry'
|
||||
|
||||
export function registerTools(
|
||||
mcpServer: McpServer,
|
||||
registry: ToolRegistry,
|
||||
ctx: ToolContext,
|
||||
ctx: ToolContext & { observer?: ToolExecutionObserver },
|
||||
): void {
|
||||
for (const tool of registry.all()) {
|
||||
const handler = async (
|
||||
@@ -15,9 +16,16 @@ export function registerTools(
|
||||
extra: { signal: AbortSignal },
|
||||
) => {
|
||||
const startTime = performance.now()
|
||||
const toolCallId = crypto.randomUUID()
|
||||
|
||||
try {
|
||||
logger.info(`${tool.name} request: ${JSON.stringify(args, null, ' ')}`)
|
||||
await ctx.observer?.onToolStart({
|
||||
toolCallId,
|
||||
toolName: tool.name,
|
||||
source: 'browser-tool',
|
||||
args,
|
||||
})
|
||||
|
||||
const result = await executeTool(tool, args, ctx, extra.signal)
|
||||
|
||||
@@ -28,6 +36,12 @@ export function registerTools(
|
||||
source: 'mcp',
|
||||
})
|
||||
|
||||
await ctx.observer?.onToolEnd({
|
||||
toolCallId,
|
||||
output: result.structuredContent ?? result.content,
|
||||
error: result.isError ? 'Tool returned isError=true' : undefined,
|
||||
})
|
||||
|
||||
return {
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
@@ -44,6 +58,11 @@ export function registerTools(
|
||||
source: 'mcp',
|
||||
})
|
||||
|
||||
await ctx.observer?.onToolEnd({
|
||||
toolCallId,
|
||||
error: errorText,
|
||||
})
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: errorText }],
|
||||
isError: true,
|
||||
|
||||
@@ -3,21 +3,27 @@
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Compose-level abstraction over PodmanRuntime.
|
||||
* Manages a single compose project for the OpenClaw gateway container.
|
||||
* OpenClaw container lifecycle abstraction over PodmanRuntime.
|
||||
*/
|
||||
|
||||
import { copyFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
OPENCLAW_COMPOSE_PROJECT_NAME,
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import type { LogFn, PodmanRuntime } from './podman-runtime'
|
||||
|
||||
const COMPOSE_FILE_NAME = 'docker-compose.yml'
|
||||
const ENV_FILE_NAME = '.env'
|
||||
const GATEWAY_CONTAINER_HOME = '/home/node'
|
||||
const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
|
||||
|
||||
export type GatewayContainerSpec = {
|
||||
image: string
|
||||
hostPort: number
|
||||
hostHome: string
|
||||
envFilePath: string
|
||||
gatewayToken?: string
|
||||
timezone: string
|
||||
}
|
||||
|
||||
export class ContainerRuntime {
|
||||
constructor(
|
||||
@@ -41,64 +47,102 @@ export class ContainerRuntime {
|
||||
return this.podman.getMachineStatus()
|
||||
}
|
||||
|
||||
async composeUp(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['up', '-d'], onLog)
|
||||
if (code !== 0) throw new Error(`compose up failed with code ${code}`)
|
||||
async pullImage(image: string, onLog?: LogFn): Promise<void> {
|
||||
const code = await this.runPodmanCommand(['pull', image], onLog)
|
||||
if (code !== 0) throw new Error(`image pull failed with code ${code}`)
|
||||
}
|
||||
|
||||
async composeDown(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['down'], onLog)
|
||||
if (code !== 0) throw new Error(`compose down failed with code ${code}`)
|
||||
async startGateway(
|
||||
input: GatewayContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
await this.ensureGatewayRemoved(onLog)
|
||||
const containerPort = String(OPENCLAW_GATEWAY_CONTAINER_PORT)
|
||||
const code = await this.runPodmanCommand(
|
||||
[
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
'--restart',
|
||||
'unless-stopped',
|
||||
'-p',
|
||||
`127.0.0.1:${input.hostPort}:${containerPort}`,
|
||||
...this.buildGatewayContainerRuntimeArgs(input),
|
||||
'--health-cmd',
|
||||
`curl -sf http://127.0.0.1:${containerPort}/healthz`,
|
||||
'--health-interval',
|
||||
'30s',
|
||||
'--health-timeout',
|
||||
'10s',
|
||||
'--health-retries',
|
||||
'3',
|
||||
input.image,
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'gateway',
|
||||
'--bind',
|
||||
'lan',
|
||||
'--port',
|
||||
containerPort,
|
||||
'--allow-unconfigured',
|
||||
],
|
||||
onLog,
|
||||
)
|
||||
if (code !== 0) throw new Error(`gateway start failed with code ${code}`)
|
||||
}
|
||||
|
||||
async composeStop(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['stop'], onLog)
|
||||
if (code !== 0) throw new Error(`compose stop failed with code ${code}`)
|
||||
async stopGateway(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.removeGatewayContainer(onLog)
|
||||
if (code !== 0) {
|
||||
throw new Error(`gateway stop failed with code ${code}`)
|
||||
}
|
||||
}
|
||||
|
||||
async composeRestart(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['restart'], onLog)
|
||||
if (code !== 0) throw new Error(`compose restart failed with code ${code}`)
|
||||
async restartGateway(
|
||||
input: GatewayContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
await this.startGateway(input, onLog)
|
||||
}
|
||||
|
||||
async composePull(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['pull', '--quiet'], onLog)
|
||||
if (code !== 0) throw new Error(`compose pull failed with code ${code}`)
|
||||
}
|
||||
|
||||
async composeLogs(tail = 50): Promise<string[]> {
|
||||
async getGatewayLogs(tail = 50): Promise<string[]> {
|
||||
const lines: string[] = []
|
||||
await this.compose(['logs', '--no-color', '--tail', String(tail)], (line) =>
|
||||
lines.push(line),
|
||||
await this.runPodmanCommand(
|
||||
['logs', '--tail', String(tail), OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
(line) => lines.push(line),
|
||||
)
|
||||
return lines
|
||||
}
|
||||
|
||||
async isHealthy(port: number): Promise<boolean> {
|
||||
async isHealthy(hostPort: number): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/healthz`)
|
||||
const res = await fetch(`http://127.0.0.1:${hostPort}/healthz`)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async isReady(port: number): Promise<boolean> {
|
||||
async isReady(hostPort: number): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/readyz`)
|
||||
const res = await fetch(`http://127.0.0.1:${hostPort}/readyz`)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async waitForReady(port: number, timeoutMs = 30_000): Promise<boolean> {
|
||||
logger.info('Waiting for OpenClaw gateway readiness', { port, timeoutMs })
|
||||
async waitForReady(hostPort: number, timeoutMs = 30_000): Promise<boolean> {
|
||||
logger.info('Waiting for OpenClaw gateway readiness', {
|
||||
hostPort,
|
||||
timeoutMs,
|
||||
})
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await this.isReady(port)) {
|
||||
if (await this.isReady(hostPort)) {
|
||||
logger.info('OpenClaw gateway became ready', {
|
||||
port,
|
||||
hostPort,
|
||||
waitMs: Date.now() - start,
|
||||
})
|
||||
return true
|
||||
@@ -106,22 +150,12 @@ export class ContainerRuntime {
|
||||
await Bun.sleep(1000)
|
||||
}
|
||||
logger.error('Timed out waiting for OpenClaw gateway readiness', {
|
||||
port,
|
||||
hostPort,
|
||||
timeoutMs,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
async copyComposeFile(sourceTemplatePath: string): Promise<void> {
|
||||
await copyFile(sourceTemplatePath, join(this.projectDir, COMPOSE_FILE_NAME))
|
||||
}
|
||||
|
||||
async writeEnvFile(content: string): Promise<void> {
|
||||
await writeFile(join(this.projectDir, ENV_FILE_NAME), content, {
|
||||
mode: 0o600,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the Podman machine only if no non-BrowserOS containers are running.
|
||||
* Prevents killing the user's own Podman workloads.
|
||||
@@ -132,8 +166,8 @@ export class ContainerRuntime {
|
||||
|
||||
try {
|
||||
const containers = await this.podman.listRunningContainers()
|
||||
const allOurs = containers.every((name) =>
|
||||
name.startsWith(OPENCLAW_COMPOSE_PROJECT_NAME),
|
||||
const allOurs = containers.every(
|
||||
(name) => name === OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
)
|
||||
|
||||
if (containers.length === 0 || allOurs) {
|
||||
@@ -155,17 +189,25 @@ export class ContainerRuntime {
|
||||
|
||||
async runGatewaySetupCommand(
|
||||
command: string[],
|
||||
spec: GatewayContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<number> {
|
||||
return this.compose(
|
||||
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
|
||||
await this.runPodmanCommand(
|
||||
['rm', '-f', '--ignore', setupContainerName],
|
||||
onLog,
|
||||
)
|
||||
const setupArgs = command[0] === 'node' ? command.slice(1) : command
|
||||
return this.runPodmanCommand(
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'--no-deps',
|
||||
'--entrypoint',
|
||||
'--name',
|
||||
setupContainerName,
|
||||
...this.buildGatewayContainerRuntimeArgs(spec),
|
||||
spec.image,
|
||||
'node',
|
||||
'openclaw-gateway',
|
||||
...command.slice(1),
|
||||
...setupArgs,
|
||||
],
|
||||
onLog,
|
||||
)
|
||||
@@ -178,15 +220,17 @@ export class ContainerRuntime {
|
||||
)
|
||||
}
|
||||
|
||||
private async compose(args: string[], onLog?: LogFn): Promise<number> {
|
||||
private async runPodmanCommand(
|
||||
args: string[],
|
||||
onLog?: LogFn,
|
||||
): Promise<number> {
|
||||
const lines: string[] = []
|
||||
const command = ['podman', 'compose', ...args].join(' ')
|
||||
logger.info('Running OpenClaw compose command', {
|
||||
const command = ['podman', ...args].join(' ')
|
||||
logger.info('Running OpenClaw podman command', {
|
||||
command,
|
||||
})
|
||||
const code = await this.podman.runCommand(['compose', ...args], {
|
||||
const code = await this.podman.runCommand(args, {
|
||||
cwd: this.projectDir,
|
||||
env: { COMPOSE_PROJECT_NAME: OPENCLAW_COMPOSE_PROJECT_NAME },
|
||||
onOutput: (line) => {
|
||||
lines.push(line)
|
||||
onLog?.(line)
|
||||
@@ -194,17 +238,58 @@ export class ContainerRuntime {
|
||||
})
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error('OpenClaw compose command failed', {
|
||||
logger.error('OpenClaw podman command failed', {
|
||||
command,
|
||||
exitCode: code,
|
||||
output: lines,
|
||||
})
|
||||
} else {
|
||||
logger.info('OpenClaw compose command succeeded', {
|
||||
logger.info('OpenClaw podman command succeeded', {
|
||||
command,
|
||||
})
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
private async ensureGatewayRemoved(onLog?: LogFn): Promise<void> {
|
||||
await this.removeGatewayContainer(onLog)
|
||||
}
|
||||
|
||||
private async removeGatewayContainer(onLog?: LogFn): Promise<number> {
|
||||
return this.runPodmanCommand(
|
||||
['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
onLog,
|
||||
)
|
||||
}
|
||||
|
||||
private buildGatewayContainerRuntimeArgs(
|
||||
input: GatewayContainerSpec,
|
||||
): string[] {
|
||||
return [
|
||||
'--env-file',
|
||||
input.envFilePath,
|
||||
'-e',
|
||||
`HOME=${GATEWAY_CONTAINER_HOME}`,
|
||||
'-e',
|
||||
`OPENCLAW_HOME=${GATEWAY_CONTAINER_HOME}`,
|
||||
'-e',
|
||||
`OPENCLAW_STATE_DIR=${GATEWAY_STATE_DIR}`,
|
||||
'-e',
|
||||
'OPENCLAW_NO_RESPAWN=1',
|
||||
'-e',
|
||||
'NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache',
|
||||
'-e',
|
||||
'NODE_ENV=production',
|
||||
'-e',
|
||||
`TZ=${input.timezone}`,
|
||||
'-v',
|
||||
`${input.hostHome}:${GATEWAY_CONTAINER_HOME}`,
|
||||
'--add-host',
|
||||
'host.containers.internal:host-gateway',
|
||||
...(input.gatewayToken
|
||||
? ['-e', `OPENCLAW_GATEWAY_TOKEN=${input.gatewayToken}`]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
*/
|
||||
|
||||
import { join } from 'node:path'
|
||||
import { OPENCLAW_GATEWAY_PORT } from '@browseros/shared/constants/openclaw'
|
||||
|
||||
// Pin away from latest because newer OpenClaw releases regress OpenRouter chat streams.
|
||||
const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:2026.4.12'
|
||||
const STATE_DIR_NAME = '.openclaw'
|
||||
|
||||
export function getOpenClawStateDir(openclawDir: string): string {
|
||||
@@ -33,26 +30,6 @@ export function getHostWorkspaceDir(
|
||||
)
|
||||
}
|
||||
|
||||
export function buildComposeEnvFile(input: {
|
||||
hostHome: string
|
||||
image?: string
|
||||
port?: number
|
||||
timezone?: string
|
||||
gatewayToken?: string
|
||||
}): string {
|
||||
const lines = [
|
||||
`OPENCLAW_IMAGE=${input.image ?? OPENCLAW_IMAGE}`,
|
||||
`OPENCLAW_GATEWAY_PORT=${input.port ?? OPENCLAW_GATEWAY_PORT}`,
|
||||
`OPENCLAW_HOST_HOME=${input.hostHome}`,
|
||||
`TZ=${input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone}`,
|
||||
]
|
||||
if (input.gatewayToken) {
|
||||
lines.push(`OPENCLAW_GATEWAY_TOKEN=${input.gatewayToken}`)
|
||||
}
|
||||
lines.push('')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function mergeEnvContent(
|
||||
current: string,
|
||||
updates: Record<string, string>,
|
||||
|
||||
@@ -7,16 +7,22 @@
|
||||
import { createParser, type EventSourceMessage } from 'eventsource-parser'
|
||||
import type { OpenClawStreamEvent } from './openclaw-types'
|
||||
|
||||
export interface OpenClawChatHistoryMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface OpenClawChatRequest {
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
message: string
|
||||
history?: OpenClawChatHistoryMessage[]
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export class OpenClawHttpChatClient {
|
||||
constructor(
|
||||
private readonly port: number,
|
||||
private readonly hostPort: number,
|
||||
private readonly getToken: () => Promise<string>,
|
||||
) {}
|
||||
|
||||
@@ -36,7 +42,7 @@ export class OpenClawHttpChatClient {
|
||||
private async fetchChat(input: OpenClawChatRequest): Promise<Response> {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${this.port}/v1/chat/completions`,
|
||||
`http://127.0.0.1:${this.hostPort}/v1/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -46,7 +52,10 @@ export class OpenClawHttpChatClient {
|
||||
body: JSON.stringify({
|
||||
model: resolveAgentModel(input.agentId),
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: input.message }],
|
||||
messages: [
|
||||
...(input.history ?? []),
|
||||
{ role: 'user', content: input.message },
|
||||
],
|
||||
user: `browseros:${input.agentId}:${input.sessionKey}`,
|
||||
}),
|
||||
signal: input.signal,
|
||||
|
||||
@@ -14,6 +14,19 @@ export const SUPPORTED_OPENCLAW_PROVIDERS = [
|
||||
export type SupportedOpenClawProvider =
|
||||
(typeof SUPPORTED_OPENCLAW_PROVIDERS)[number]
|
||||
|
||||
export interface CustomOpenClawProviderConfig {
|
||||
providerId: string
|
||||
apiKeyEnvVar: string
|
||||
config: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ResolvedOpenClawProviderConfig {
|
||||
envValues: Record<string, string>
|
||||
model?: string
|
||||
providerType?: SupportedOpenClawProvider
|
||||
customProvider?: CustomOpenClawProviderConfig
|
||||
}
|
||||
|
||||
const PROVIDER_ENV_VARS: Record<SupportedOpenClawProvider, string> = {
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
moonshot: 'MOONSHOT_API_KEY',
|
||||
@@ -65,6 +78,30 @@ export function buildOpenClawModelRef(
|
||||
return modelId ? `${providerType}/${modelId}` : undefined
|
||||
}
|
||||
|
||||
export function deriveOpenClawProviderId(input: {
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
}): string {
|
||||
const source =
|
||||
input.providerName?.trim() ||
|
||||
input.baseUrl?.trim() ||
|
||||
input.providerType?.trim() ||
|
||||
'custom-provider'
|
||||
|
||||
const candidate = source
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
return candidate || 'custom-provider'
|
||||
}
|
||||
|
||||
export function deriveOpenClawApiKeyEnvVar(providerId: string): string {
|
||||
return `${providerId.toUpperCase().replace(/-/g, '_')}_API_KEY`
|
||||
}
|
||||
|
||||
export function getOpenClawProviderEnvVar(
|
||||
providerType: SupportedOpenClawProvider,
|
||||
): string {
|
||||
@@ -77,20 +114,44 @@ export function resolveSupportedOpenClawProvider(input: {
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}): {
|
||||
envValues: Record<string, string>
|
||||
model?: string
|
||||
providerType?: SupportedOpenClawProvider
|
||||
} {
|
||||
const providerType = assertSupportedOpenClawProvider(input.providerType)
|
||||
if (!providerType) {
|
||||
}): ResolvedOpenClawProviderConfig {
|
||||
if (!input.providerType) {
|
||||
return { envValues: {} }
|
||||
}
|
||||
|
||||
const envVar = getOpenClawProviderEnvVar(providerType)
|
||||
if (isSupportedOpenClawProvider(input.providerType)) {
|
||||
const providerType = input.providerType
|
||||
const envVar = getOpenClawProviderEnvVar(providerType)
|
||||
return {
|
||||
envValues: input.apiKey ? { [envVar]: input.apiKey } : {},
|
||||
model: buildOpenClawModelRef(providerType, input.modelId),
|
||||
providerType,
|
||||
}
|
||||
}
|
||||
|
||||
if (!input.baseUrl) {
|
||||
throw new UnsupportedOpenClawProviderError(input.providerType)
|
||||
}
|
||||
|
||||
const providerId = deriveOpenClawProviderId(input)
|
||||
const apiKeyEnvVar = deriveOpenClawApiKeyEnvVar(providerId)
|
||||
|
||||
return {
|
||||
envValues: input.apiKey ? { [envVar]: input.apiKey } : {},
|
||||
model: buildOpenClawModelRef(providerType, input.modelId),
|
||||
providerType,
|
||||
envValues: input.apiKey ? { [apiKeyEnvVar]: input.apiKey } : {},
|
||||
model: input.modelId ? `${providerId}/${input.modelId}` : undefined,
|
||||
customProvider: {
|
||||
providerId,
|
||||
apiKeyEnvVar,
|
||||
config: {
|
||||
api: 'openai-completions',
|
||||
baseUrl: input.baseUrl,
|
||||
apiKey: `\${${apiKeyEnvVar}}`,
|
||||
...(input.modelId
|
||||
? {
|
||||
models: [{ id: input.modelId, name: input.modelId }],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,18 @@
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import {
|
||||
OPENCLAW_CONTAINER_HOME,
|
||||
OPENCLAW_GATEWAY_PORT,
|
||||
OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
|
||||
import { getOpenClawDir } from '../../../lib/browseros-dir'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import { ContainerRuntime } from './container-runtime'
|
||||
import type { MonitoringChatTurn } from '../../../monitoring/types'
|
||||
import {
|
||||
ContainerRuntime,
|
||||
type GatewayContainerSpec,
|
||||
} from './container-runtime'
|
||||
import {
|
||||
OpenClawAgentAlreadyExistsError,
|
||||
OpenClawAgentNotFoundError,
|
||||
@@ -31,7 +34,6 @@ import {
|
||||
type OpenClawConfigBatchEntry,
|
||||
} from './openclaw-cli-client'
|
||||
import {
|
||||
buildComposeEnvFile,
|
||||
getHostWorkspaceDir,
|
||||
getOpenClawStateConfigPath,
|
||||
getOpenClawStateDir,
|
||||
@@ -39,31 +41,18 @@ import {
|
||||
mergeEnvContent,
|
||||
} from './openclaw-env'
|
||||
import { OpenClawHttpChatClient } from './openclaw-http-chat-client'
|
||||
import { resolveSupportedOpenClawProvider } from './openclaw-provider-map'
|
||||
import {
|
||||
type ResolvedOpenClawProviderConfig,
|
||||
resolveSupportedOpenClawProvider,
|
||||
} from './openclaw-provider-map'
|
||||
import type { OpenClawStreamEvent } from './openclaw-types'
|
||||
import { getPodmanRuntime } from './podman-runtime'
|
||||
import { loadPodmanOverrides, savePodmanOverrides } from './podman-overrides'
|
||||
import { configurePodmanRuntime, getPodmanRuntime } from './podman-runtime'
|
||||
import { allocateGatewayPort, readPersistedGatewayPort } from './runtime-state'
|
||||
|
||||
export const SOURCE_COMPOSE_RESOURCE = resolve(
|
||||
import.meta.dir,
|
||||
'../../../../resources/openclaw-compose.yml',
|
||||
)
|
||||
const READY_TIMEOUT_MS = 30_000
|
||||
const AGENT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/
|
||||
|
||||
export function resolveComposeResourcePath(resourcesDir?: string): string {
|
||||
if (resourcesDir) {
|
||||
const bundledComposePath = join(resourcesDir, 'openclaw-compose.yml')
|
||||
if (existsSync(bundledComposePath)) {
|
||||
return bundledComposePath
|
||||
}
|
||||
logger.warn(
|
||||
'Bundled openclaw-compose.yml not found in resourcesDir, falling back to source tree',
|
||||
{ resourcesDir },
|
||||
)
|
||||
}
|
||||
return SOURCE_COMPOSE_RESOURCE
|
||||
}
|
||||
|
||||
export type OpenClawControlPlaneStatus =
|
||||
| 'disconnected'
|
||||
| 'connecting'
|
||||
@@ -121,39 +110,42 @@ export interface OpenClawServiceConfig {
|
||||
resourcesDir?: string
|
||||
}
|
||||
|
||||
export interface OpenClawPodmanOverridesResponse {
|
||||
podmanPath: string | null
|
||||
effectivePodmanPath: string
|
||||
}
|
||||
|
||||
export class OpenClawService {
|
||||
private runtime: ContainerRuntime
|
||||
private cliClient: OpenClawCliClient
|
||||
private bootstrapCliClient: OpenClawCliClient
|
||||
private chatClient: OpenClawHttpChatClient
|
||||
private openclawDir: string
|
||||
private composeResourcePath: string
|
||||
private port = OPENCLAW_GATEWAY_PORT
|
||||
private hostPort = OPENCLAW_GATEWAY_CONTAINER_PORT
|
||||
private token: string
|
||||
private tokenLoaded = false
|
||||
private lastError: string | null = null
|
||||
private browserosServerPort: number
|
||||
private resourcesDir: string | null
|
||||
private controlPlaneStatus: OpenClawControlPlaneStatus = 'disconnected'
|
||||
private lastGatewayError: string | null = null
|
||||
private lastRecoveryReason: OpenClawGatewayRecoveryReason | null = null
|
||||
private stopLogTail: (() => void) | null = null
|
||||
private lifecycleLock: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(config: OpenClawServiceConfig = {}) {
|
||||
this.openclawDir = getOpenClawDir()
|
||||
this.runtime = new ContainerRuntime(getPodmanRuntime(), this.openclawDir)
|
||||
this.token = crypto.randomUUID()
|
||||
this.cliClient = new OpenClawCliClient(this.runtime)
|
||||
this.bootstrapCliClient = new OpenClawCliClient({
|
||||
execInContainer: (command, onLog) =>
|
||||
this.runtime.runGatewaySetupCommand(command, onLog),
|
||||
})
|
||||
this.bootstrapCliClient = this.buildBootstrapCliClient()
|
||||
this.chatClient = new OpenClawHttpChatClient(
|
||||
this.port,
|
||||
this.hostPort,
|
||||
async () => this.token,
|
||||
)
|
||||
this.composeResourcePath = resolveComposeResourcePath(config.resourcesDir)
|
||||
this.browserosServerPort =
|
||||
config.browserosServerPort ?? DEFAULT_PORTS.server
|
||||
this.resourcesDir = config.resourcesDir ?? null
|
||||
}
|
||||
|
||||
configure(config: OpenClawServiceConfig): void {
|
||||
@@ -161,217 +153,268 @@ export class OpenClawService {
|
||||
this.browserosServerPort = config.browserosServerPort
|
||||
}
|
||||
if (config.resourcesDir !== undefined) {
|
||||
this.composeResourcePath = resolveComposeResourcePath(config.resourcesDir)
|
||||
this.resourcesDir = config.resourcesDir
|
||||
}
|
||||
}
|
||||
|
||||
getPort(): number {
|
||||
return this.hostPort
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
async setup(input: SetupInput, onLog?: (msg: string) => void): Promise<void> {
|
||||
const logProgress = this.createProgressLogger(onLog)
|
||||
const provider = resolveSupportedOpenClawProvider(input)
|
||||
logger.info('Starting OpenClaw setup', {
|
||||
port: this.port,
|
||||
browserosServerPort: this.browserosServerPort,
|
||||
providerType: input.providerType,
|
||||
providerName: input.providerName,
|
||||
hasBaseUrl: !!input.baseUrl,
|
||||
hasModel: !!input.modelId,
|
||||
hasApiKey: !!input.apiKey,
|
||||
})
|
||||
return this.withLifecycleLock('setup', async () => {
|
||||
const logProgress = this.createProgressLogger(onLog)
|
||||
const provider = resolveSupportedOpenClawProvider(input)
|
||||
logger.info('Starting OpenClaw setup', {
|
||||
hostPort: this.hostPort,
|
||||
browserosServerPort: this.browserosServerPort,
|
||||
providerType: input.providerType,
|
||||
providerName: input.providerName,
|
||||
hasBaseUrl: !!input.baseUrl,
|
||||
hasModel: !!input.modelId,
|
||||
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.',
|
||||
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')
|
||||
|
||||
await mkdir(this.openclawDir, { recursive: true })
|
||||
await mkdir(this.getStateDir(), { recursive: true })
|
||||
await mkdir(this.getHostWorkspaceDir('main'), { recursive: true })
|
||||
|
||||
await this.ensureStateEnvFile()
|
||||
await this.writeStateEnv(provider.envValues)
|
||||
logger.info('Updated OpenClaw state env', {
|
||||
providerKeyCount: Object.keys(provider.envValues).length,
|
||||
})
|
||||
|
||||
logProgress('Pulling OpenClaw image...')
|
||||
await this.runtime.pullImage(this.getGatewayImage(), logProgress)
|
||||
logProgress('Image ready')
|
||||
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
|
||||
logProgress('Bootstrapping OpenClaw config...')
|
||||
await this.bootstrapCliClient.runOnboard({
|
||||
acceptRisk: true,
|
||||
authChoice: 'skip',
|
||||
gatewayAuth: 'token',
|
||||
gatewayBind: 'lan',
|
||||
gatewayPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
installDaemon: false,
|
||||
mode: 'local',
|
||||
nonInteractive: true,
|
||||
skipHealth: true,
|
||||
})
|
||||
await this.applyBrowserosConfig()
|
||||
await this.mergeProviderConfigIfChanged(provider)
|
||||
if (provider.model) {
|
||||
await this.bootstrapCliClient.setDefaultModel(provider.model)
|
||||
}
|
||||
|
||||
logProgress('Validating OpenClaw config...')
|
||||
await this.assertConfigValid(this.bootstrapCliClient)
|
||||
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
|
||||
logProgress('Starting OpenClaw gateway...')
|
||||
await this.runtime.startGateway(
|
||||
this.buildGatewayRuntimeSpec(),
|
||||
logProgress,
|
||||
)
|
||||
}
|
||||
|
||||
await this.runtime.ensureReady(logProgress)
|
||||
logProgress('Container runtime ready')
|
||||
|
||||
await mkdir(this.openclawDir, { recursive: true })
|
||||
await mkdir(this.getStateDir(), { recursive: true })
|
||||
await mkdir(this.getHostWorkspaceDir('main'), { recursive: true })
|
||||
|
||||
logProgress('Copying compose file...')
|
||||
await this.runtime.copyComposeFile(this.composeResourcePath)
|
||||
|
||||
await this.writeComposeEnv()
|
||||
logProgress('Generated .env file')
|
||||
logger.info('Wrote OpenClaw env file', {
|
||||
openclawDir: this.openclawDir,
|
||||
})
|
||||
|
||||
await this.ensureStateEnvFile()
|
||||
await this.writeStateEnv(provider.envValues)
|
||||
logger.info('Updated OpenClaw state env', {
|
||||
providerKeyCount: Object.keys(provider.envValues).length,
|
||||
})
|
||||
|
||||
logProgress('Pulling OpenClaw image...')
|
||||
await this.runtime.composePull(logProgress)
|
||||
logProgress('Image ready')
|
||||
|
||||
logProgress('Bootstrapping OpenClaw config...')
|
||||
await this.bootstrapCliClient.runOnboard({
|
||||
acceptRisk: true,
|
||||
authChoice: 'skip',
|
||||
gatewayAuth: 'token',
|
||||
gatewayBind: 'lan',
|
||||
gatewayPort: this.port,
|
||||
installDaemon: false,
|
||||
mode: 'local',
|
||||
nonInteractive: true,
|
||||
skipHealth: true,
|
||||
})
|
||||
await this.applyBrowserosConfig()
|
||||
if (provider.model) {
|
||||
await this.bootstrapCliClient.setDefaultModel(provider.model)
|
||||
}
|
||||
|
||||
logProgress('Validating OpenClaw config...')
|
||||
await this.assertConfigValid(this.bootstrapCliClient)
|
||||
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
await this.writeComposeEnv()
|
||||
|
||||
logProgress('Starting OpenClaw gateway...')
|
||||
await this.runtime.composeUp(logProgress)
|
||||
this.startGatewayLogTail()
|
||||
logProgress('Waiting for gateway readiness...')
|
||||
const ready = await this.runtime.waitForReady(this.port, READY_TIMEOUT_MS)
|
||||
if (!ready) {
|
||||
this.lastError = 'Gateway did not become ready within 30 seconds'
|
||||
const logs = await this.runtime.composeLogs()
|
||||
logger.error('Gateway readiness check failed', { logs })
|
||||
throw new Error(this.lastError)
|
||||
}
|
||||
|
||||
this.controlPlaneStatus = 'connecting'
|
||||
logProgress('Probing OpenClaw control plane...')
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
|
||||
const existingAgents = await this.listAgents()
|
||||
logger.info('Fetched existing OpenClaw agents after setup', {
|
||||
count: existingAgents.length,
|
||||
names: existingAgents.map((agent) => agent.name),
|
||||
})
|
||||
if (existingAgents.some((agent) => agent.agentId === 'main')) {
|
||||
logProgress('Main agent detected')
|
||||
} else {
|
||||
logProgress('Creating main agent...')
|
||||
await this.runControlPlaneCall(() =>
|
||||
this.cliClient.createAgent({
|
||||
name: 'main',
|
||||
model: provider.model,
|
||||
}),
|
||||
this.startGatewayLogTail()
|
||||
logProgress('Waiting for gateway readiness...')
|
||||
const ready = await this.runtime.waitForReady(
|
||||
this.hostPort,
|
||||
READY_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
if (!ready) {
|
||||
this.lastError = 'Gateway did not become ready within 30 seconds'
|
||||
const logs = await this.runtime.getGatewayLogs()
|
||||
logger.error('Gateway readiness check failed', { logs })
|
||||
throw new Error(this.lastError)
|
||||
}
|
||||
|
||||
this.lastError = null
|
||||
logProgress(`OpenClaw gateway running at http://127.0.0.1:${this.port}`)
|
||||
logger.info('OpenClaw setup complete', { port: this.port })
|
||||
this.controlPlaneStatus = 'connecting'
|
||||
logProgress('Probing OpenClaw control plane...')
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
|
||||
const existingAgents = await this.listAgents()
|
||||
logger.info('Fetched existing OpenClaw agents after setup', {
|
||||
count: existingAgents.length,
|
||||
names: existingAgents.map((agent) => agent.name),
|
||||
})
|
||||
if (existingAgents.some((agent) => agent.agentId === 'main')) {
|
||||
logProgress('Main agent detected')
|
||||
} else {
|
||||
logProgress('Creating main agent...')
|
||||
await this.runControlPlaneCall(() =>
|
||||
this.cliClient.createAgent({
|
||||
name: 'main',
|
||||
model: provider.model,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
this.lastError = null
|
||||
logProgress(
|
||||
`OpenClaw gateway running at http://127.0.0.1:${this.hostPort}`,
|
||||
)
|
||||
logger.info('OpenClaw setup complete', { hostPort: this.hostPort })
|
||||
})
|
||||
}
|
||||
|
||||
async start(onLog?: (msg: string) => void): Promise<void> {
|
||||
const logProgress = this.createProgressLogger(onLog)
|
||||
logger.info('Starting OpenClaw service', {
|
||||
port: this.port,
|
||||
return this.withLifecycleLock('start', async () => {
|
||||
const logProgress = this.createProgressLogger(onLog)
|
||||
logger.info('Starting OpenClaw service', {
|
||||
hostPort: this.hostPort,
|
||||
})
|
||||
|
||||
await this.runtime.ensureReady(logProgress)
|
||||
|
||||
logProgress('Refreshing gateway auth token...')
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
await this.ensureStateEnvFile()
|
||||
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
|
||||
if (await this.isGatewayAvailable(this.hostPort)) {
|
||||
this.startGatewayLogTail()
|
||||
this.controlPlaneStatus = 'connecting'
|
||||
logProgress('Probing OpenClaw control plane...')
|
||||
try {
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
this.lastError = null
|
||||
logger.info('OpenClaw gateway already running', {
|
||||
hostPort: this.hostPort,
|
||||
})
|
||||
return
|
||||
} catch (error) {
|
||||
logger.warn('OpenClaw control plane probe failed during start', {
|
||||
hostPort: this.hostPort,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logProgress('Starting OpenClaw gateway...')
|
||||
await this.runtime.startGateway(
|
||||
this.buildGatewayRuntimeSpec(),
|
||||
logProgress,
|
||||
)
|
||||
this.startGatewayLogTail()
|
||||
|
||||
logProgress('Waiting for gateway readiness...')
|
||||
const ready = await this.runtime.waitForReady(
|
||||
this.hostPort,
|
||||
READY_TIMEOUT_MS,
|
||||
)
|
||||
if (!ready) {
|
||||
this.lastError = 'Gateway did not become ready after start'
|
||||
throw new Error(this.lastError)
|
||||
}
|
||||
|
||||
this.controlPlaneStatus = 'connecting'
|
||||
logProgress('Probing OpenClaw control plane...')
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
this.lastError = null
|
||||
logger.info('OpenClaw gateway started', { hostPort: this.hostPort })
|
||||
})
|
||||
|
||||
await this.runtime.ensureReady(logProgress)
|
||||
|
||||
logProgress('Refreshing gateway auth token...')
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
await this.ensureStateEnvFile()
|
||||
await this.writeComposeEnv()
|
||||
|
||||
logProgress('Starting OpenClaw gateway...')
|
||||
await this.runtime.composeUp(logProgress)
|
||||
this.startGatewayLogTail()
|
||||
|
||||
logProgress('Waiting for gateway readiness...')
|
||||
const ready = await this.runtime.waitForReady(this.port, READY_TIMEOUT_MS)
|
||||
if (!ready) {
|
||||
this.lastError = 'Gateway did not become ready after start'
|
||||
throw new Error(this.lastError)
|
||||
}
|
||||
|
||||
this.controlPlaneStatus = 'connecting'
|
||||
logProgress('Probing OpenClaw control plane...')
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
this.lastError = null
|
||||
logger.info('OpenClaw gateway started', { port: this.port })
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
logger.info('Stopping OpenClaw service', { port: this.port })
|
||||
this.controlPlaneStatus = 'disconnected'
|
||||
this.stopGatewayLogTail()
|
||||
await this.runtime.composeStop()
|
||||
logger.info('OpenClaw container stopped')
|
||||
return this.withLifecycleLock('stop', async () => {
|
||||
logger.info('Stopping OpenClaw service', { hostPort: this.hostPort })
|
||||
this.controlPlaneStatus = 'disconnected'
|
||||
this.stopGatewayLogTail()
|
||||
await this.runtime.stopGateway()
|
||||
logger.info('OpenClaw container stopped')
|
||||
})
|
||||
}
|
||||
|
||||
async restart(onLog?: (msg: string) => void): Promise<void> {
|
||||
const logProgress = this.createProgressLogger(onLog)
|
||||
logger.info('Restarting OpenClaw service', {
|
||||
port: this.port,
|
||||
return this.withLifecycleLock('restart', async () => {
|
||||
const logProgress = this.createProgressLogger(onLog)
|
||||
logger.info('Restarting OpenClaw service', {
|
||||
hostPort: this.hostPort,
|
||||
})
|
||||
|
||||
this.controlPlaneStatus = 'reconnecting'
|
||||
this.stopGatewayLogTail()
|
||||
logProgress('Refreshing gateway auth token...')
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
await this.ensureStateEnvFile()
|
||||
await this.ensureGatewayPortAllocated(logProgress)
|
||||
logProgress('Restarting OpenClaw gateway...')
|
||||
await this.runtime.restartGateway(
|
||||
this.buildGatewayRuntimeSpec(),
|
||||
logProgress,
|
||||
)
|
||||
this.startGatewayLogTail()
|
||||
|
||||
logProgress('Waiting for gateway readiness...')
|
||||
const ready = await this.runtime.waitForReady(
|
||||
this.hostPort,
|
||||
READY_TIMEOUT_MS,
|
||||
)
|
||||
if (!ready) {
|
||||
this.lastError = 'Gateway did not become ready after restart'
|
||||
throw new Error(this.lastError)
|
||||
}
|
||||
|
||||
logProgress('Probing OpenClaw control plane...')
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
this.lastError = null
|
||||
logProgress('Gateway restarted successfully')
|
||||
logger.info('OpenClaw gateway restarted', { hostPort: this.hostPort })
|
||||
})
|
||||
|
||||
this.controlPlaneStatus = 'reconnecting'
|
||||
this.stopGatewayLogTail()
|
||||
logProgress('Restarting OpenClaw gateway...')
|
||||
await this.runtime.composeRestart(logProgress)
|
||||
this.startGatewayLogTail()
|
||||
|
||||
logProgress('Waiting for gateway readiness...')
|
||||
const ready = await this.runtime.waitForReady(this.port, READY_TIMEOUT_MS)
|
||||
if (!ready) {
|
||||
this.lastError = 'Gateway did not become ready after restart'
|
||||
throw new Error(this.lastError)
|
||||
}
|
||||
|
||||
logProgress('Refreshing gateway auth token...')
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
logProgress('Probing OpenClaw control plane...')
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
this.lastError = null
|
||||
logProgress('Gateway restarted successfully')
|
||||
logger.info('OpenClaw gateway restarted', { port: this.port })
|
||||
}
|
||||
|
||||
async reconnectControlPlane(onLog?: (msg: string) => void): Promise<void> {
|
||||
const logProgress = this.createProgressLogger(onLog)
|
||||
logger.info('Reconnecting OpenClaw control plane', { port: this.port })
|
||||
return this.withLifecycleLock('reconnect', async () => {
|
||||
const logProgress = this.createProgressLogger(onLog)
|
||||
logger.info('Reconnecting OpenClaw control plane', {
|
||||
hostPort: this.hostPort,
|
||||
})
|
||||
|
||||
logProgress('Checking gateway readiness...')
|
||||
const ready = await this.runtime.isReady(this.port)
|
||||
if (!ready) {
|
||||
this.controlPlaneStatus = 'failed'
|
||||
this.lastGatewayError = 'OpenClaw gateway is not ready'
|
||||
this.lastRecoveryReason = 'container_not_ready'
|
||||
throw new Error('OpenClaw gateway is not ready')
|
||||
}
|
||||
logProgress('Checking gateway readiness...')
|
||||
const ready = await this.runtime.isReady(this.hostPort)
|
||||
if (!ready) {
|
||||
this.controlPlaneStatus = 'failed'
|
||||
this.lastGatewayError = 'OpenClaw gateway is not ready'
|
||||
this.lastRecoveryReason = 'container_not_ready'
|
||||
throw new Error('OpenClaw gateway is not ready')
|
||||
}
|
||||
|
||||
logProgress('Reloading gateway auth token...')
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
this.controlPlaneStatus = 'reconnecting'
|
||||
logProgress('Reconnecting control plane...')
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
logProgress('Control plane connected')
|
||||
logProgress('Reloading gateway auth token...')
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
this.controlPlaneStatus = 'reconnecting'
|
||||
logProgress('Reconnecting control plane...')
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
logProgress('Control plane connected')
|
||||
})
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.controlPlaneStatus = 'disconnected'
|
||||
this.stopGatewayLogTail()
|
||||
try {
|
||||
await this.runtime.composeStop()
|
||||
await this.runtime.stopGateway()
|
||||
} catch {
|
||||
// Best effort during shutdown
|
||||
}
|
||||
@@ -415,7 +458,7 @@ export class OpenClawService {
|
||||
|
||||
const machineStatus = await this.runtime.getMachineStatus()
|
||||
const ready = machineStatus.running
|
||||
? await this.runtime.isReady(this.port)
|
||||
? await this.runtime.isReady(this.hostPort)
|
||||
: false
|
||||
|
||||
let agentCount = 0
|
||||
@@ -434,7 +477,7 @@ export class OpenClawService {
|
||||
status: ready ? 'running' : this.lastError ? 'error' : 'stopped',
|
||||
podmanAvailable: true,
|
||||
machineReady: machineStatus.running,
|
||||
port: this.port,
|
||||
port: this.hostPort,
|
||||
agentCount,
|
||||
error: this.lastError,
|
||||
controlPlaneStatus: ready ? this.controlPlaneStatus : 'disconnected',
|
||||
@@ -469,11 +512,13 @@ export class OpenClawService {
|
||||
await this.assertGatewayReady()
|
||||
|
||||
const provider = resolveSupportedOpenClawProvider(input)
|
||||
const configChanged = await this.mergeProviderConfigIfChanged(provider)
|
||||
const keysChanged = await this.writeStateEnv(provider.envValues)
|
||||
|
||||
if (keysChanged) {
|
||||
if (configChanged || keysChanged) {
|
||||
logger.info('OpenClaw provider config changed while creating agent', {
|
||||
name,
|
||||
configChanged,
|
||||
keysChanged,
|
||||
})
|
||||
await this.restart()
|
||||
@@ -534,22 +579,63 @@ export class OpenClawService {
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
message: string,
|
||||
history: MonitoringChatTurn[] = [],
|
||||
): Promise<ReadableStream<OpenClawStreamEvent>> {
|
||||
await this.assertGatewayReady()
|
||||
logger.info('Starting OpenClaw chat stream', {
|
||||
agentId,
|
||||
sessionKey,
|
||||
messageLength: message.length,
|
||||
historyLength: history.length,
|
||||
})
|
||||
return this.runControlPlaneCall(() =>
|
||||
this.chatClient.streamChat({
|
||||
agentId,
|
||||
sessionKey,
|
||||
message,
|
||||
history,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Podman Overrides ─────────────────────────────────────────────────
|
||||
|
||||
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 getPodmanOverrides(): Promise<OpenClawPodmanOverridesResponse> {
|
||||
const { podmanPath } = await loadPodmanOverrides(this.openclawDir)
|
||||
return {
|
||||
podmanPath,
|
||||
effectivePodmanPath: getPodmanRuntime().getPodmanPath(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Provider Keys ────────────────────────────────────────────────────
|
||||
|
||||
async updateProviderKeys(input: {
|
||||
@@ -560,21 +646,23 @@ export class OpenClawService {
|
||||
modelId?: string
|
||||
}): Promise<OpenClawProviderUpdateResult> {
|
||||
const provider = resolveSupportedOpenClawProvider(input)
|
||||
const configChanged = await this.mergeProviderConfigIfChanged(provider)
|
||||
const envChanged = await this.writeStateEnv(provider.envValues)
|
||||
const restarted = configChanged || envChanged
|
||||
if (restarted) {
|
||||
await this.restart()
|
||||
}
|
||||
if (provider.model) {
|
||||
const model = provider.model
|
||||
await this.applyCliMutation(() => this.cliClient.setDefaultModel(model))
|
||||
}
|
||||
const changed = await this.writeStateEnv(provider.envValues)
|
||||
if (changed) {
|
||||
await this.restart()
|
||||
}
|
||||
logger.info('Provider keys updated', {
|
||||
providerType: input.providerType,
|
||||
modelUpdated: !!provider.model,
|
||||
restarted: changed,
|
||||
restarted,
|
||||
})
|
||||
return {
|
||||
restarted: changed,
|
||||
restarted,
|
||||
modelUpdated: !!provider.model,
|
||||
}
|
||||
}
|
||||
@@ -583,56 +671,121 @@ export class OpenClawService {
|
||||
|
||||
async getLogs(tail = 100): Promise<string[]> {
|
||||
logger.debug('Fetching OpenClaw container logs', { tail })
|
||||
return this.runtime.composeLogs(tail)
|
||||
return this.runtime.getGatewayLogs(tail)
|
||||
}
|
||||
|
||||
// ── Auto-start on BrowserOS boot ────────────────────────────────────
|
||||
|
||||
async tryAutoStart(): Promise<void> {
|
||||
const isSetUp = existsSync(this.getStateConfigPath())
|
||||
if (!isSetUp) return
|
||||
return this.withLifecycleLock('auto-start', async () => {
|
||||
const isSetUp = existsSync(this.getStateConfigPath())
|
||||
if (!isSetUp) return
|
||||
|
||||
const available = await this.runtime.isPodmanAvailable()
|
||||
if (!available) return
|
||||
logger.info('Attempting OpenClaw auto-start', {
|
||||
port: this.port,
|
||||
})
|
||||
|
||||
try {
|
||||
await this.runtime.ensureReady()
|
||||
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
await this.ensureStateEnvFile()
|
||||
await this.writeComposeEnv()
|
||||
|
||||
if (!(await this.runtime.isReady(this.port))) {
|
||||
await this.runtime.composeUp()
|
||||
const ready = await this.runtime.waitForReady(
|
||||
this.port,
|
||||
READY_TIMEOUT_MS,
|
||||
)
|
||||
if (!ready) {
|
||||
logger.warn('OpenClaw gateway failed to become ready on auto-start')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
logger.info('OpenClaw gateway auto-started')
|
||||
} catch (err) {
|
||||
logger.warn('OpenClaw auto-start failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
const available = await this.runtime.isPodmanAvailable()
|
||||
if (!available) return
|
||||
logger.info('Attempting OpenClaw auto-start', {
|
||||
hostPort: this.hostPort,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await this.runtime.ensureReady()
|
||||
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
await this.ensureStateEnvFile()
|
||||
|
||||
const persistedPort = await readPersistedGatewayPort(this.openclawDir)
|
||||
if (persistedPort !== null) {
|
||||
this.setPort(persistedPort)
|
||||
}
|
||||
|
||||
if (!(await this.runtime.isReady(this.hostPort))) {
|
||||
await this.ensureGatewayPortAllocated()
|
||||
await this.runtime.startGateway(this.buildGatewayRuntimeSpec())
|
||||
const ready = await this.runtime.waitForReady(
|
||||
this.hostPort,
|
||||
READY_TIMEOUT_MS,
|
||||
)
|
||||
if (!ready) {
|
||||
logger.warn('OpenClaw gateway failed to become ready on auto-start')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await this.runControlPlaneCall(() => this.cliClient.probe())
|
||||
logger.info('OpenClaw gateway auto-started')
|
||||
} catch (err) {
|
||||
logger.warn('OpenClaw auto-start failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────
|
||||
|
||||
private buildBootstrapCliClient(): OpenClawCliClient {
|
||||
return new OpenClawCliClient({
|
||||
execInContainer: (command, onLog) =>
|
||||
this.runtime.runGatewaySetupCommand(
|
||||
command,
|
||||
this.buildGatewayRuntimeSpec(),
|
||||
onLog,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
private rebuildRuntimeClients(): void {
|
||||
this.stopGatewayLogTail()
|
||||
this.runtime = new ContainerRuntime(getPodmanRuntime(), this.openclawDir)
|
||||
this.cliClient = new OpenClawCliClient(this.runtime)
|
||||
this.bootstrapCliClient = this.buildBootstrapCliClient()
|
||||
}
|
||||
|
||||
private setPort(hostPort: number): void {
|
||||
if (hostPort === this.hostPort) return
|
||||
this.hostPort = hostPort
|
||||
this.chatClient = new OpenClawHttpChatClient(
|
||||
this.hostPort,
|
||||
async () => this.token,
|
||||
)
|
||||
}
|
||||
|
||||
private async ensureGatewayPortAllocated(
|
||||
logProgress?: (msg: string) => void,
|
||||
): Promise<void> {
|
||||
const persistedPort = await readPersistedGatewayPort(this.openclawDir)
|
||||
if (persistedPort !== null) {
|
||||
this.setPort(persistedPort)
|
||||
}
|
||||
if (await this.isGatewayAvailable(this.hostPort)) {
|
||||
return
|
||||
}
|
||||
const hostPort = await allocateGatewayPort(this.openclawDir)
|
||||
if (hostPort !== this.hostPort) {
|
||||
logProgress?.(`Allocated OpenClaw gateway host port ${hostPort}`)
|
||||
logger.info('Allocated OpenClaw gateway host port', { hostPort })
|
||||
this.setPort(hostPort)
|
||||
}
|
||||
}
|
||||
|
||||
private async isGatewayAvailable(hostPort: number): Promise<boolean> {
|
||||
if (await this.runtime.isReady(hostPort)) {
|
||||
return true
|
||||
}
|
||||
const runtime = this.runtime as {
|
||||
isHealthy?: (port: number) => Promise<boolean>
|
||||
}
|
||||
if (runtime.isHealthy) {
|
||||
return runtime.isHealthy(hostPort)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private async assertGatewayReady(): Promise<void> {
|
||||
const portReady = await this.runtime.isReady(this.port)
|
||||
const portReady = await this.runtime.isReady(this.hostPort)
|
||||
logger.debug('Checking OpenClaw gateway readiness before use', {
|
||||
port: this.port,
|
||||
hostPort: this.hostPort,
|
||||
portReady,
|
||||
controlPlaneStatus: this.controlPlaneStatus,
|
||||
})
|
||||
@@ -736,8 +889,8 @@ export class OpenClawService {
|
||||
{
|
||||
path: 'gateway.controlUi.allowedOrigins',
|
||||
value: [
|
||||
`http://127.0.0.1:${this.port}`,
|
||||
`http://localhost:${this.port}`,
|
||||
`http://127.0.0.1:${this.hostPort}`,
|
||||
`http://localhost:${this.hostPort}`,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -847,7 +1000,10 @@ export class OpenClawService {
|
||||
}
|
||||
|
||||
private async waitForGatewayAfterCliMutation(): Promise<void> {
|
||||
const ready = await this.runtime.waitForReady(this.port, READY_TIMEOUT_MS)
|
||||
const ready = await this.runtime.waitForReady(
|
||||
this.hostPort,
|
||||
READY_TIMEOUT_MS,
|
||||
)
|
||||
if (!ready) {
|
||||
this.lastError = 'Gateway did not become ready after applying config'
|
||||
throw new Error(this.lastError)
|
||||
@@ -875,13 +1031,20 @@ export class OpenClawService {
|
||||
await writeFile(envPath, '', { mode: 0o600 })
|
||||
}
|
||||
|
||||
private async writeComposeEnv(): Promise<void> {
|
||||
const envContent = buildComposeEnvFile({
|
||||
// Pin away from latest because newer OpenClaw releases regress OpenRouter chat streams.
|
||||
private getGatewayImage(): string {
|
||||
return process.env.OPENCLAW_IMAGE || 'ghcr.io/openclaw/openclaw:2026.4.12'
|
||||
}
|
||||
|
||||
private buildGatewayRuntimeSpec(): GatewayContainerSpec {
|
||||
return {
|
||||
image: this.getGatewayImage(),
|
||||
hostPort: this.hostPort,
|
||||
hostHome: this.openclawDir,
|
||||
port: this.port,
|
||||
envFilePath: this.getStateEnvPath(),
|
||||
gatewayToken: this.tokenLoaded ? this.token : undefined,
|
||||
})
|
||||
await this.runtime.writeEnvFile(envContent)
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
}
|
||||
}
|
||||
|
||||
private async writeStateEnv(
|
||||
@@ -908,6 +1071,82 @@ export class OpenClawService {
|
||||
return true
|
||||
}
|
||||
|
||||
private async mergeProviderConfigIfChanged(
|
||||
provider: ResolvedOpenClawProviderConfig,
|
||||
): Promise<boolean> {
|
||||
if (!provider.customProvider) {
|
||||
return false
|
||||
}
|
||||
|
||||
const configPath = this.getStateConfigPath()
|
||||
const content = await readFile(configPath, 'utf-8')
|
||||
const config = JSON.parse(content) as Record<string, unknown>
|
||||
const models =
|
||||
config.models && typeof config.models === 'object'
|
||||
? (config.models as Record<string, unknown>)
|
||||
: {}
|
||||
const providers =
|
||||
models.providers && typeof models.providers === 'object'
|
||||
? (models.providers as Record<string, Record<string, unknown>>)
|
||||
: {}
|
||||
const existingProvider = providers[provider.customProvider.providerId] ?? {}
|
||||
const existingModels = Array.isArray(existingProvider.models)
|
||||
? (existingProvider.models as Array<Record<string, unknown>>)
|
||||
: []
|
||||
const desiredModelEntry =
|
||||
Array.isArray(provider.customProvider.config.models) &&
|
||||
provider.customProvider.config.models.length > 0
|
||||
? (provider.customProvider.config.models[0] as Record<string, unknown>)
|
||||
: null
|
||||
const hasDesiredModel = desiredModelEntry
|
||||
? existingModels.some(
|
||||
(model) =>
|
||||
model.id === desiredModelEntry.id ||
|
||||
model.name === desiredModelEntry.name,
|
||||
)
|
||||
: true
|
||||
const mergedModels =
|
||||
desiredModelEntry && !hasDesiredModel
|
||||
? [...existingModels, desiredModelEntry]
|
||||
: existingModels.length > 0
|
||||
? existingModels
|
||||
: Array.isArray(provider.customProvider.config.models)
|
||||
? provider.customProvider.config.models
|
||||
: undefined
|
||||
|
||||
const nextProvider: Record<string, unknown> = {
|
||||
...existingProvider,
|
||||
...provider.customProvider.config,
|
||||
...(mergedModels ? { models: mergedModels } : {}),
|
||||
}
|
||||
const nextModels: Record<string, unknown> = {
|
||||
...models,
|
||||
mode: 'merge',
|
||||
providers: {
|
||||
...providers,
|
||||
[provider.customProvider.providerId]: nextProvider,
|
||||
},
|
||||
}
|
||||
const nextConfig: Record<string, unknown> = {
|
||||
...config,
|
||||
models: nextModels,
|
||||
}
|
||||
|
||||
if (JSON.stringify(config) === JSON.stringify(nextConfig)) {
|
||||
return false
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(nextConfig, null, 2)}\n`,
|
||||
'utf-8',
|
||||
)
|
||||
logger.debug('Updated OpenClaw custom provider config', {
|
||||
providerId: provider.customProvider.providerId,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private async ensureTokenLoaded(): Promise<void> {
|
||||
if (this.tokenLoaded) {
|
||||
return
|
||||
@@ -951,6 +1190,24 @@ export class OpenClawService {
|
||||
onLog?.(msg)
|
||||
}
|
||||
}
|
||||
|
||||
private async withLifecycleLock<T>(
|
||||
operation: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previous = this.lifecycleLock
|
||||
let release!: () => void
|
||||
this.lifecycleLock = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
await previous.catch(() => undefined)
|
||||
try {
|
||||
logger.debug('OpenClaw lifecycle operation started', { operation })
|
||||
return await fn()
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let service: OpenClawService | null = null
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Persistence for user-supplied Podman runtime overrides.
|
||||
* Temporary escape hatch so users can point BrowserOS at their own Podman
|
||||
* (e.g. `brew install podman`) when the bundled runtime doesn't resolve helpers.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export interface PodmanOverrides {
|
||||
podmanPath: string | null
|
||||
}
|
||||
|
||||
const OVERRIDES_FILE_NAME = 'podman-overrides.json'
|
||||
|
||||
export function getPodmanOverridesPath(openclawDir: string): string {
|
||||
return join(openclawDir, OVERRIDES_FILE_NAME)
|
||||
}
|
||||
|
||||
export async function loadPodmanOverrides(
|
||||
openclawDir: string,
|
||||
): Promise<PodmanOverrides> {
|
||||
const overridesPath = getPodmanOverridesPath(openclawDir)
|
||||
if (!existsSync(overridesPath)) return { podmanPath: null }
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
await readFile(overridesPath, 'utf-8'),
|
||||
) as Partial<PodmanOverrides>
|
||||
return {
|
||||
podmanPath:
|
||||
typeof parsed.podmanPath === 'string' && parsed.podmanPath.length > 0
|
||||
? parsed.podmanPath
|
||||
: null,
|
||||
}
|
||||
} catch {
|
||||
return { podmanPath: null }
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePodmanOverrides(
|
||||
openclawDir: string,
|
||||
overrides: PodmanOverrides,
|
||||
): Promise<void> {
|
||||
await mkdir(openclawDir, { recursive: true })
|
||||
await writeFile(
|
||||
getPodmanOverridesPath(openclawDir),
|
||||
`${JSON.stringify(overrides, null, 2)}\n`,
|
||||
)
|
||||
}
|
||||
@@ -37,7 +37,6 @@ export function resolveBundledPodmanPath(
|
||||
|
||||
export class PodmanRuntime {
|
||||
private podmanPath: string
|
||||
private machineReady = false
|
||||
|
||||
constructor(config?: { podmanPath?: string }) {
|
||||
this.podmanPath = config?.podmanPath ?? 'podman'
|
||||
@@ -138,12 +137,9 @@ export class PodmanRuntime {
|
||||
const code = await proc.exited
|
||||
if (code !== 0)
|
||||
throw new Error(`podman machine stop failed with code ${code}`)
|
||||
this.machineReady = false
|
||||
}
|
||||
|
||||
async ensureReady(onLog?: LogFn): Promise<void> {
|
||||
if (this.machineReady) return
|
||||
|
||||
const status = await this.getMachineStatus()
|
||||
|
||||
if (!status.initialized) {
|
||||
@@ -155,8 +151,6 @@ export class PodmanRuntime {
|
||||
onLog?.('Starting Podman machine...')
|
||||
await this.startMachine(onLog)
|
||||
}
|
||||
|
||||
this.machineReady = true
|
||||
}
|
||||
|
||||
async runCommand(
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Runtime state for the OpenClaw gateway. Today this is just the host port
|
||||
* we mapped the gateway container to, persisted so that a once-chosen port
|
||||
* is reused across restarts when it's still free.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { createServer } from 'node:net'
|
||||
import { join } from 'node:path'
|
||||
import { OPENCLAW_GATEWAY_CONTAINER_PORT } from '@browseros/shared/constants/openclaw'
|
||||
import { getOpenClawStateDir } from './openclaw-env'
|
||||
|
||||
const RUNTIME_STATE_FILE = 'runtime-state.json'
|
||||
|
||||
interface RuntimeState {
|
||||
gatewayPort: number
|
||||
}
|
||||
|
||||
function getRuntimeStatePath(openclawDir: string): string {
|
||||
return join(getOpenClawStateDir(openclawDir), RUNTIME_STATE_FILE)
|
||||
}
|
||||
|
||||
export async function readPersistedGatewayPort(
|
||||
openclawDir: string,
|
||||
): Promise<number | null> {
|
||||
const path = getRuntimeStatePath(openclawDir)
|
||||
if (!existsSync(path)) return null
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
await readFile(path, 'utf-8'),
|
||||
) as Partial<RuntimeState>
|
||||
if (
|
||||
typeof parsed.gatewayPort === 'number' &&
|
||||
Number.isInteger(parsed.gatewayPort) &&
|
||||
parsed.gatewayPort > 0 &&
|
||||
parsed.gatewayPort <= 65535
|
||||
) {
|
||||
return parsed.gatewayPort
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function writePersistedGatewayPort(
|
||||
openclawDir: string,
|
||||
port: number,
|
||||
): Promise<void> {
|
||||
await mkdir(getOpenClawStateDir(openclawDir), { recursive: true })
|
||||
const state: RuntimeState = { gatewayPort: port }
|
||||
await writeFile(
|
||||
getRuntimeStatePath(openclawDir),
|
||||
`${JSON.stringify(state, null, 2)}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer()
|
||||
server.once('error', () => resolve(false))
|
||||
server.once('listening', () => {
|
||||
server.close(() => resolve(true))
|
||||
})
|
||||
server.listen(port, '127.0.0.1')
|
||||
})
|
||||
}
|
||||
|
||||
async function findAvailablePort(startPort: number): Promise<number> {
|
||||
let port = startPort
|
||||
while (!(await isPortAvailable(port))) {
|
||||
port++
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a host port for the gateway container and persist it. Prefers the
|
||||
* previously persisted port when it's still bindable; otherwise scans
|
||||
* upward from OPENCLAW_GATEWAY_CONTAINER_PORT until a free port is found.
|
||||
*/
|
||||
export async function allocateGatewayPort(
|
||||
openclawDir: string,
|
||||
): Promise<number> {
|
||||
const persisted = await readPersistedGatewayPort(openclawDir)
|
||||
if (persisted !== null && (await isPortAvailable(persisted))) {
|
||||
return persisted
|
||||
}
|
||||
const port = await findAvailablePort(OPENCLAW_GATEWAY_CONTAINER_PORT)
|
||||
await writePersistedGatewayPort(openclawDir, port)
|
||||
return port
|
||||
}
|
||||
@@ -6,12 +6,10 @@ 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'
|
||||
? DEV_BROWSEROS_DIR_NAME
|
||||
? PATHS.DEV_BROWSEROS_DIR_NAME
|
||||
: PATHS.BROWSEROS_DIR_NAME
|
||||
return join(homedir(), dirName)
|
||||
}
|
||||
@@ -49,6 +47,30 @@ export function getOpenClawDir(): 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 getAgentCacheDir(): string {
|
||||
return join(getVmCacheDir(), 'images')
|
||||
}
|
||||
|
||||
export function getLazyMonitoringDir(): string {
|
||||
return join(getBrowserosDir(), 'lazy-monitoring')
|
||||
}
|
||||
|
||||
export function getLazyMonitoringRunsDir(): string {
|
||||
return join(getLazyMonitoringDir(), 'runs')
|
||||
}
|
||||
|
||||
export function getLazyMonitoringRunDir(runId: string): string {
|
||||
return join(getLazyMonitoringRunsDir(), runId)
|
||||
}
|
||||
|
||||
export function getServerConfigPath(): string {
|
||||
return join(getBrowserosDir(), PATHS.SERVER_CONFIG_FILE_NAME)
|
||||
}
|
||||
@@ -73,6 +95,8 @@ export async function ensureBrowserosDir(): Promise<void> {
|
||||
await mkdir(getSkillsDir(), { recursive: true })
|
||||
await mkdir(getBuiltinSkillsDir(), { recursive: true })
|
||||
await mkdir(getSessionsDir(), { recursive: true })
|
||||
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
|
||||
await mkdir(getAgentCacheDir(), { recursive: true })
|
||||
}
|
||||
|
||||
export async function cleanOldSessions(): Promise<void> {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
configureOpenClawService,
|
||||
getOpenClawService,
|
||||
} from './api/services/openclaw/openclaw-service'
|
||||
import { loadPodmanOverrides } from './api/services/openclaw/podman-overrides'
|
||||
import { configurePodmanRuntime } from './api/services/openclaw/podman-runtime'
|
||||
import { CdpBackend } from './browser/backends/cdp'
|
||||
import { Browser } from './browser/browser'
|
||||
@@ -25,6 +26,7 @@ import { INLINED_ENV } from './env'
|
||||
import {
|
||||
cleanOldSessions,
|
||||
ensureBrowserosDir,
|
||||
getOpenClawDir,
|
||||
removeServerConfigSync,
|
||||
writeServerConfig,
|
||||
} from './lib/browseros-dir'
|
||||
@@ -59,9 +61,17 @@ export class Application {
|
||||
resourcesDir: path.resolve(this.config.resourcesDir),
|
||||
})
|
||||
|
||||
const resourcesDir = path.resolve(this.config.resourcesDir)
|
||||
const podmanOverrides = await loadPodmanOverrides(getOpenClawDir())
|
||||
configurePodmanRuntime({
|
||||
resourcesDir: path.resolve(this.config.resourcesDir),
|
||||
resourcesDir,
|
||||
podmanPath: podmanOverrides.podmanPath ?? undefined,
|
||||
})
|
||||
if (podmanOverrides.podmanPath) {
|
||||
logger.info('Using user-overridden Podman binary', {
|
||||
podmanPath: podmanOverrides.podmanPath,
|
||||
})
|
||||
}
|
||||
await this.initCoreServices()
|
||||
|
||||
if (!this.config.cdpPort) {
|
||||
@@ -128,7 +138,7 @@ export class Application {
|
||||
|
||||
configureOpenClawService({
|
||||
browserosServerPort: this.config.serverPort,
|
||||
resourcesDir: path.resolve(this.config.resourcesDir),
|
||||
resourcesDir,
|
||||
})
|
||||
.tryAutoStart()
|
||||
.catch((err) =>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type {
|
||||
JudgeAuditEnvelope,
|
||||
MonitoringFinalization,
|
||||
MonitoringSessionContext,
|
||||
MonitoringToolCallRecord,
|
||||
} from './types'
|
||||
|
||||
export function buildJudgeAuditEnvelope(input: {
|
||||
context: MonitoringSessionContext
|
||||
toolCalls: MonitoringToolCallRecord[]
|
||||
finalization: MonitoringFinalization | null
|
||||
}): JudgeAuditEnvelope {
|
||||
const envelope: JudgeAuditEnvelope = {
|
||||
run: input.context,
|
||||
toolCalls: input.toolCalls,
|
||||
}
|
||||
|
||||
if (input.finalization) {
|
||||
envelope.finalization = input.finalization
|
||||
}
|
||||
|
||||
return envelope
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { logger } from '../lib/logger'
|
||||
import type { MonitoringToolEndInput, MonitoringToolStartInput } from './types'
|
||||
|
||||
export interface ToolExecutionObserver {
|
||||
onToolStart(input: MonitoringToolStartInput): Promise<void>
|
||||
onToolEnd(input: MonitoringToolEndInput): Promise<void>
|
||||
}
|
||||
|
||||
export function swallowMonitoringError(
|
||||
operation: string,
|
||||
error: unknown,
|
||||
metadata: Record<string, unknown>,
|
||||
): void {
|
||||
logger.warn(`Lazy monitoring ${operation} failed`, {
|
||||
...metadata,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
222
packages/browseros-agent/apps/server/src/monitoring/service.ts
Normal file
222
packages/browseros-agent/apps/server/src/monitoring/service.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { buildJudgeAuditEnvelope } from './envelope'
|
||||
import { swallowMonitoringError, type ToolExecutionObserver } from './observer'
|
||||
import { MonitoringSessionRegistry } from './session-registry'
|
||||
import { MonitoringStorage } from './storage'
|
||||
import type {
|
||||
JudgeAuditEnvelope,
|
||||
MonitoringFinalization,
|
||||
MonitoringFinalizeInput,
|
||||
MonitoringRunSummary,
|
||||
MonitoringSessionContext,
|
||||
MonitoringSessionStartInput,
|
||||
MonitoringToolCallRecord,
|
||||
MonitoringToolEndInput,
|
||||
MonitoringToolStartInput,
|
||||
} from './types'
|
||||
|
||||
type ActiveToolCallState = Omit<
|
||||
MonitoringToolCallRecord,
|
||||
'finishedAt' | 'durationMs' | 'error' | 'output'
|
||||
>
|
||||
|
||||
export class MonitoringService {
|
||||
private readonly storage = new MonitoringStorage()
|
||||
private readonly registry = new MonitoringSessionRegistry()
|
||||
|
||||
async startSession(
|
||||
input: MonitoringSessionStartInput,
|
||||
): Promise<MonitoringSessionContext> {
|
||||
const context: MonitoringSessionContext = {
|
||||
monitoringSessionId: crypto.randomUUID(),
|
||||
agentId: input.agentId,
|
||||
sessionKey: input.sessionKey,
|
||||
originalPrompt: input.originalPrompt,
|
||||
chatHistory: input.chatHistory,
|
||||
startedAt: new Date().toISOString(),
|
||||
source: input.source ?? 'openclaw-agent-chat',
|
||||
}
|
||||
|
||||
await this.storage.writeContext(context)
|
||||
this.registry.setActive(context.agentId, context.monitoringSessionId)
|
||||
return context
|
||||
}
|
||||
|
||||
getActiveSessionId(agentId: string): string | undefined {
|
||||
return this.registry.getActive(agentId)
|
||||
}
|
||||
|
||||
getSingleActiveSession():
|
||||
| { agentId: string; monitoringSessionId: string }
|
||||
| undefined {
|
||||
return this.registry.getSingleActive()
|
||||
}
|
||||
clearActiveSession(agentId: string, monitoringSessionId: string): void {
|
||||
this.registry.clearIfMatches(agentId, monitoringSessionId)
|
||||
}
|
||||
|
||||
createObserver(
|
||||
monitoringSessionId: string,
|
||||
agentId: string,
|
||||
): ToolExecutionObserver {
|
||||
const activeToolCalls = new Map<string, ActiveToolCallState>()
|
||||
|
||||
return {
|
||||
onToolStart: async (input: MonitoringToolStartInput) => {
|
||||
try {
|
||||
activeToolCalls.set(input.toolCallId, {
|
||||
monitoringSessionId,
|
||||
agentId,
|
||||
toolCallId: input.toolCallId,
|
||||
toolName: input.toolName,
|
||||
source: input.source,
|
||||
args: input.args,
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
swallowMonitoringError('tool start recording', error, {
|
||||
monitoringSessionId,
|
||||
agentId,
|
||||
toolCallId: input.toolCallId,
|
||||
toolName: input.toolName,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
onToolEnd: async (input: MonitoringToolEndInput) => {
|
||||
try {
|
||||
const active = activeToolCalls.get(input.toolCallId)
|
||||
if (!active) return
|
||||
|
||||
const finishedAt = new Date().toISOString()
|
||||
const durationMs = Math.max(
|
||||
0,
|
||||
new Date(finishedAt).getTime() -
|
||||
new Date(active.startedAt).getTime(),
|
||||
)
|
||||
|
||||
const record: MonitoringToolCallRecord = {
|
||||
...active,
|
||||
finishedAt,
|
||||
durationMs,
|
||||
}
|
||||
|
||||
if (input.error) {
|
||||
record.error = input.error
|
||||
}
|
||||
if (input.output !== undefined) {
|
||||
record.output = input.output
|
||||
}
|
||||
|
||||
await this.storage.appendToolCall(record)
|
||||
activeToolCalls.delete(input.toolCallId)
|
||||
} catch (error) {
|
||||
swallowMonitoringError('tool end recording', error, {
|
||||
monitoringSessionId,
|
||||
agentId,
|
||||
toolCallId: input.toolCallId,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async finalizeSession(
|
||||
input: MonitoringFinalizeInput,
|
||||
): Promise<JudgeAuditEnvelope | null> {
|
||||
const context = await this.storage.readContext(input.monitoringSessionId)
|
||||
if (!context) {
|
||||
return null
|
||||
}
|
||||
|
||||
const finalization: MonitoringFinalization = {
|
||||
monitoringSessionId: input.monitoringSessionId,
|
||||
agentId: input.agentId,
|
||||
sessionKey: input.sessionKey,
|
||||
status: input.status,
|
||||
finalizedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (input.finalAssistantMessage) {
|
||||
finalization.finalAssistantMessage = input.finalAssistantMessage
|
||||
}
|
||||
if (input.error) {
|
||||
finalization.error = input.error
|
||||
}
|
||||
|
||||
await this.storage.writeFinalization(finalization)
|
||||
this.registry.clearIfMatches(input.agentId, input.monitoringSessionId)
|
||||
return this.buildAndPersistEnvelope(input.monitoringSessionId)
|
||||
}
|
||||
|
||||
async getRunEnvelope(runId: string): Promise<JudgeAuditEnvelope | null> {
|
||||
const context = await this.storage.readContext(runId)
|
||||
if (!context) return null
|
||||
|
||||
const toolCalls = await this.storage.readToolCalls(runId)
|
||||
const finalization = await this.storage.readFinalization(runId)
|
||||
|
||||
return buildJudgeAuditEnvelope({
|
||||
context,
|
||||
toolCalls,
|
||||
finalization,
|
||||
})
|
||||
}
|
||||
|
||||
async listRuns(limit = 50): Promise<MonitoringRunSummary[]> {
|
||||
const runIds = (await this.storage.listRunIds()).slice(0, limit)
|
||||
const summaries = await Promise.all(
|
||||
runIds.map(async (runId) => {
|
||||
const context = await this.storage.readContext(runId)
|
||||
if (!context) return null
|
||||
|
||||
const [toolCalls, finalization] = await Promise.all([
|
||||
this.storage.readToolCalls(runId),
|
||||
this.storage.readFinalization(runId),
|
||||
])
|
||||
|
||||
const summary: MonitoringRunSummary = {
|
||||
monitoringSessionId: context.monitoringSessionId,
|
||||
agentId: context.agentId,
|
||||
sessionKey: context.sessionKey,
|
||||
originalPrompt: context.originalPrompt,
|
||||
startedAt: context.startedAt,
|
||||
source: context.source,
|
||||
toolCallCount: toolCalls.length,
|
||||
}
|
||||
|
||||
if (finalization) {
|
||||
summary.finalization = {
|
||||
status: finalization.status,
|
||||
finalizedAt: finalization.finalizedAt,
|
||||
error: finalization.error,
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
}),
|
||||
)
|
||||
|
||||
return summaries.filter((summary): summary is MonitoringRunSummary =>
|
||||
Boolean(summary),
|
||||
)
|
||||
}
|
||||
|
||||
private async buildAndPersistEnvelope(
|
||||
runId: string,
|
||||
): Promise<JudgeAuditEnvelope | null> {
|
||||
const envelope = await this.getRunEnvelope(runId)
|
||||
if (!envelope) return null
|
||||
|
||||
await this.storage.writeAuditEnvelope(runId, envelope)
|
||||
return envelope
|
||||
}
|
||||
}
|
||||
|
||||
let monitoringService: MonitoringService | null = null
|
||||
|
||||
export function getMonitoringService(): MonitoringService {
|
||||
if (!monitoringService) {
|
||||
monitoringService = new MonitoringService()
|
||||
}
|
||||
return monitoringService
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export class MonitoringSessionRegistry {
|
||||
private readonly activeSessionsByAgent = new Map<string, string>()
|
||||
|
||||
setActive(agentId: string, monitoringSessionId: string): void {
|
||||
this.activeSessionsByAgent.set(agentId, monitoringSessionId)
|
||||
}
|
||||
|
||||
getActive(agentId: string): string | undefined {
|
||||
return this.activeSessionsByAgent.get(agentId)
|
||||
}
|
||||
|
||||
getSingleActive():
|
||||
| { agentId: string; monitoringSessionId: string }
|
||||
| undefined {
|
||||
if (this.activeSessionsByAgent.size !== 1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const [agentId, monitoringSessionId] =
|
||||
this.activeSessionsByAgent.entries().next().value ?? []
|
||||
|
||||
if (!agentId || !monitoringSessionId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { agentId, monitoringSessionId }
|
||||
}
|
||||
clearIfMatches(agentId: string, monitoringSessionId: string): void {
|
||||
if (this.activeSessionsByAgent.get(agentId) !== monitoringSessionId) {
|
||||
return
|
||||
}
|
||||
this.activeSessionsByAgent.delete(agentId)
|
||||
}
|
||||
}
|
||||
175
packages/browseros-agent/apps/server/src/monitoring/storage.ts
Normal file
175
packages/browseros-agent/apps/server/src/monitoring/storage.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
appendFile,
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
stat,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
getLazyMonitoringRunDir,
|
||||
getLazyMonitoringRunsDir,
|
||||
} from '../lib/browseros-dir'
|
||||
import type {
|
||||
MonitoringFinalization,
|
||||
MonitoringSessionContext,
|
||||
MonitoringToolCallRecord,
|
||||
} from './types'
|
||||
|
||||
const CONTEXT_FILE_NAME = 'context.json'
|
||||
const TOOL_CALLS_FILE_NAME = 'tool-calls.jsonl'
|
||||
const FINALIZATION_FILE_NAME = 'finalization.json'
|
||||
const AUDIT_ENVELOPE_FILE_NAME = 'audit-envelope.json'
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
export class InvalidMonitoringRunIdError extends Error {
|
||||
constructor(runId: string) {
|
||||
super(`Invalid monitoring run id: ${runId}`)
|
||||
this.name = 'InvalidMonitoringRunIdError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidMonitoringRunId(runId: string): boolean {
|
||||
return UUID_PATTERN.test(runId)
|
||||
}
|
||||
|
||||
function assertValidMonitoringRunId(runId: string): void {
|
||||
if (!isValidMonitoringRunId(runId)) {
|
||||
throw new InvalidMonitoringRunIdError(runId)
|
||||
}
|
||||
}
|
||||
|
||||
export class MonitoringStorage {
|
||||
async writeContext(context: MonitoringSessionContext): Promise<void> {
|
||||
await this.ensureRunDir(context.monitoringSessionId)
|
||||
await writeFile(
|
||||
this.getContextPath(context.monitoringSessionId),
|
||||
`${JSON.stringify(context, null, 2)}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
async appendToolCall(record: MonitoringToolCallRecord): Promise<void> {
|
||||
await this.ensureRunDir(record.monitoringSessionId)
|
||||
await appendFile(
|
||||
this.getToolCallsPath(record.monitoringSessionId),
|
||||
`${JSON.stringify(record)}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
async writeFinalization(finalization: MonitoringFinalization): Promise<void> {
|
||||
await this.ensureRunDir(finalization.monitoringSessionId)
|
||||
await writeFile(
|
||||
this.getFinalizationPath(finalization.monitoringSessionId),
|
||||
`${JSON.stringify(finalization, null, 2)}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
async writeAuditEnvelope(runId: string, envelope: unknown): Promise<void> {
|
||||
await this.ensureRunDir(runId)
|
||||
await writeFile(
|
||||
this.getAuditEnvelopePath(runId),
|
||||
`${JSON.stringify(envelope, null, 2)}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
async readContext(runId: string): Promise<MonitoringSessionContext | null> {
|
||||
return this.readJsonFile<MonitoringSessionContext>(
|
||||
this.getContextPath(runId),
|
||||
)
|
||||
}
|
||||
|
||||
async readFinalization(
|
||||
runId: string,
|
||||
): Promise<MonitoringFinalization | null> {
|
||||
return this.readJsonFile<MonitoringFinalization>(
|
||||
this.getFinalizationPath(runId),
|
||||
)
|
||||
}
|
||||
|
||||
async readToolCalls(runId: string): Promise<MonitoringToolCallRecord[]> {
|
||||
try {
|
||||
const content = await readFile(this.getToolCallsPath(runId), 'utf8')
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.flatMap((line) => {
|
||||
try {
|
||||
return [JSON.parse(line) as MonitoringToolCallRecord]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async listRunIds(): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(getLazyMonitoringRunsDir(), {
|
||||
withFileTypes: true,
|
||||
})
|
||||
const directories = entries.filter(
|
||||
(entry) => entry.isDirectory() && isValidMonitoringRunId(entry.name),
|
||||
)
|
||||
const runStats = await Promise.all(
|
||||
directories.map(async (entry) => ({
|
||||
runId: entry.name,
|
||||
mtimeMs: await this.getDirectoryMtimeMs(entry.name),
|
||||
})),
|
||||
)
|
||||
return runStats
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||
.map((entry) => entry.runId)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRunDir(runId: string): Promise<void> {
|
||||
assertValidMonitoringRunId(runId)
|
||||
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
|
||||
await mkdir(getLazyMonitoringRunDir(runId), { recursive: true })
|
||||
}
|
||||
|
||||
private async getDirectoryMtimeMs(runId: string): Promise<number> {
|
||||
try {
|
||||
const info = await stat(getLazyMonitoringRunDir(runId))
|
||||
return info.mtimeMs
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private async readJsonFile<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const content = await readFile(path, 'utf8')
|
||||
return JSON.parse(content) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private getContextPath(runId: string): string {
|
||||
assertValidMonitoringRunId(runId)
|
||||
return join(getLazyMonitoringRunDir(runId), CONTEXT_FILE_NAME)
|
||||
}
|
||||
|
||||
private getToolCallsPath(runId: string): string {
|
||||
assertValidMonitoringRunId(runId)
|
||||
return join(getLazyMonitoringRunDir(runId), TOOL_CALLS_FILE_NAME)
|
||||
}
|
||||
|
||||
private getFinalizationPath(runId: string): string {
|
||||
assertValidMonitoringRunId(runId)
|
||||
return join(getLazyMonitoringRunDir(runId), FINALIZATION_FILE_NAME)
|
||||
}
|
||||
|
||||
private getAuditEnvelopePath(runId: string): string {
|
||||
assertValidMonitoringRunId(runId)
|
||||
return join(getLazyMonitoringRunDir(runId), AUDIT_ENVELOPE_FILE_NAME)
|
||||
}
|
||||
}
|
||||
92
packages/browseros-agent/apps/server/src/monitoring/types.ts
Normal file
92
packages/browseros-agent/apps/server/src/monitoring/types.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export type MonitoringChatTurnRole = 'user' | 'assistant'
|
||||
|
||||
export interface MonitoringChatTurn {
|
||||
role: MonitoringChatTurnRole
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface MonitoringSessionContext {
|
||||
monitoringSessionId: string
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
originalPrompt: string
|
||||
chatHistory: MonitoringChatTurn[]
|
||||
startedAt: string
|
||||
source: 'openclaw-agent-chat' | 'debug'
|
||||
}
|
||||
|
||||
export type MonitoringToolCallSource = 'browser-tool' | 'klavis-tool'
|
||||
|
||||
export interface MonitoringToolCallRecord {
|
||||
monitoringSessionId: string
|
||||
agentId: string
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
source: MonitoringToolCallSource
|
||||
args: unknown
|
||||
output?: unknown
|
||||
error?: string
|
||||
startedAt: string
|
||||
finishedAt?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
export interface MonitoringFinalization {
|
||||
monitoringSessionId: string
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
status: 'completed' | 'failed' | 'aborted' | 'incomplete'
|
||||
finalAssistantMessage?: string
|
||||
error?: string
|
||||
finalizedAt: string
|
||||
}
|
||||
|
||||
export interface JudgeAuditEnvelope {
|
||||
run: MonitoringSessionContext
|
||||
toolCalls: MonitoringToolCallRecord[]
|
||||
finalization?: MonitoringFinalization
|
||||
}
|
||||
|
||||
export interface MonitoringRunSummary {
|
||||
monitoringSessionId: string
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
originalPrompt: string
|
||||
startedAt: string
|
||||
source: MonitoringSessionContext['source']
|
||||
toolCallCount: number
|
||||
finalization?: Pick<
|
||||
MonitoringFinalization,
|
||||
'status' | 'finalizedAt' | 'error'
|
||||
>
|
||||
}
|
||||
|
||||
export interface MonitoringSessionStartInput {
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
originalPrompt: string
|
||||
chatHistory: MonitoringChatTurn[]
|
||||
source?: MonitoringSessionContext['source']
|
||||
}
|
||||
|
||||
export interface MonitoringToolStartInput {
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
source: MonitoringToolCallSource
|
||||
args: unknown
|
||||
}
|
||||
|
||||
export interface MonitoringToolEndInput {
|
||||
toolCallId: string
|
||||
output?: unknown
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface MonitoringFinalizeInput {
|
||||
monitoringSessionId: string
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
status: MonitoringFinalization['status']
|
||||
finalAssistantMessage?: string
|
||||
error?: string
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { UnsupportedOpenClawProviderError } from '../../../src/api/services/openclaw/openclaw-provider-map'
|
||||
|
||||
describe('createOpenClawRoutes', () => {
|
||||
@@ -11,7 +14,7 @@ describe('createOpenClawRoutes', () => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
it('preserves BrowserOS SSE framing and session headers for chat', async () => {
|
||||
it('preserves BrowserOS SSE framing, session headers, and defaults chat history for chat', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
@@ -57,7 +60,7 @@ describe('createOpenClawRoutes', () => {
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toContain('text/event-stream')
|
||||
expect(response.headers.get('X-Session-Key')).toBe('session-123')
|
||||
expect(chatStream).toHaveBeenCalledWith('research', 'session-123', 'hi')
|
||||
expect(chatStream).toHaveBeenCalledWith('research', 'session-123', 'hi', [])
|
||||
expect(await response.text()).toBe(
|
||||
'data: {"type":"text-delta","data":{"text":"Hello"}}\n\n' +
|
||||
'data: {"type":"done","data":{"text":"Hello"}}\n\n' +
|
||||
@@ -65,6 +68,107 @@ describe('createOpenClawRoutes', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('passes prior chat history through to the OpenClaw chat stream', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const chatStream = mock(
|
||||
async () =>
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: 'done',
|
||||
data: { text: 'Done' },
|
||||
})
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () =>
|
||||
({
|
||||
chatStream,
|
||||
}) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
const history = [
|
||||
{ role: 'user' as const, content: 'Find my open tasks' },
|
||||
{ role: 'assistant' as const, content: 'I am checking Linear now.' },
|
||||
]
|
||||
|
||||
const response = await route.request('/agents/research/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: 'Summarize what is blocked',
|
||||
sessionKey: 'session-456',
|
||||
history,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(chatStream).toHaveBeenCalledWith(
|
||||
'research',
|
||||
'session-456',
|
||||
'Summarize what is blocked',
|
||||
history,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects concurrent monitored chat requests for the same agent', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const actualMonitoringService = await import(
|
||||
'../../../src/monitoring/service'
|
||||
)
|
||||
const chatStream = mock(async () => new ReadableStream())
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () =>
|
||||
({
|
||||
chatStream,
|
||||
}) as never,
|
||||
}))
|
||||
|
||||
mock.module('../../../src/monitoring/service', () => ({
|
||||
...actualMonitoringService,
|
||||
getMonitoringService: () =>
|
||||
({
|
||||
getActiveSessionId: (agentId: string) =>
|
||||
agentId === 'research' ? 'existing-run' : undefined,
|
||||
}) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/agents/research/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: 'hi',
|
||||
sessionKey: 'session-789',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(chatStream).not.toHaveBeenCalled()
|
||||
expect(await response.json()).toEqual({
|
||||
error:
|
||||
'A monitored chat session is already active for this agent. Wait for it to finish before starting another.',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 400 for unsupported provider payloads', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
@@ -160,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'
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
|
||||
import {
|
||||
parseTerminalClientMessage,
|
||||
serializeTerminalServerMessage,
|
||||
@@ -53,7 +54,7 @@ describe('terminal protocol', () => {
|
||||
expect(
|
||||
buildTerminalExecCommand(
|
||||
'podman',
|
||||
'browseros-openclaw-openclaw-gateway-1',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
TERMINAL_HOME_DIR,
|
||||
),
|
||||
).toEqual([
|
||||
@@ -62,7 +63,7 @@ describe('terminal protocol', () => {
|
||||
'-it',
|
||||
'-w',
|
||||
'/home/node/.openclaw',
|
||||
'browseros-openclaw-openclaw-gateway-1',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
'/bin/sh',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
|
||||
import { ContainerRuntime } from '../../../../src/api/services/openclaw/container-runtime'
|
||||
|
||||
const PROJECT_DIR = '/tmp/openclaw'
|
||||
const defaultSpec = {
|
||||
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
hostPort: 18789,
|
||||
hostHome: '/tmp/openclaw',
|
||||
envFilePath: '/tmp/openclaw/.openclaw/.env',
|
||||
gatewayToken: 'token-123',
|
||||
timezone: 'America/Los_Angeles',
|
||||
}
|
||||
|
||||
function createRuntime(
|
||||
runCommand: (
|
||||
args: string[],
|
||||
options?: { cwd?: string; onOutput?: (line: string) => void },
|
||||
) => Promise<number>,
|
||||
listRunningContainers: () => Promise<string[]> = async () => [],
|
||||
stopMachine: () => Promise<void> = async () => {},
|
||||
): ContainerRuntime {
|
||||
return new ContainerRuntime(
|
||||
{
|
||||
ensureReady: async () => {},
|
||||
isPodmanAvailable: async () => true,
|
||||
getMachineStatus: async () => ({ initialized: true, running: true }),
|
||||
runCommand,
|
||||
tailContainerLogs: () => () => {},
|
||||
listRunningContainers,
|
||||
stopMachine,
|
||||
} as never,
|
||||
PROJECT_DIR,
|
||||
)
|
||||
}
|
||||
|
||||
function expectedGatewayRuntimeArgs(spec: typeof defaultSpec): string[] {
|
||||
return [
|
||||
'--env-file',
|
||||
spec.envFilePath,
|
||||
'-e',
|
||||
'HOME=/home/node',
|
||||
'-e',
|
||||
'OPENCLAW_HOME=/home/node',
|
||||
'-e',
|
||||
'OPENCLAW_STATE_DIR=/home/node/.openclaw',
|
||||
'-e',
|
||||
'OPENCLAW_NO_RESPAWN=1',
|
||||
'-e',
|
||||
'NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache',
|
||||
'-e',
|
||||
'NODE_ENV=production',
|
||||
'-e',
|
||||
`TZ=${spec.timezone}`,
|
||||
'-v',
|
||||
`${spec.hostHome}:/home/node`,
|
||||
'--add-host',
|
||||
'host.containers.internal:host-gateway',
|
||||
'-e',
|
||||
`OPENCLAW_GATEWAY_TOKEN=${spec.gatewayToken}`,
|
||||
]
|
||||
}
|
||||
|
||||
function expectedStartGatewayRunArgs(spec: typeof defaultSpec): string[] {
|
||||
return [
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
'--restart',
|
||||
'unless-stopped',
|
||||
'-p',
|
||||
`127.0.0.1:${spec.hostPort}:18789`,
|
||||
...expectedGatewayRuntimeArgs(spec),
|
||||
'--health-cmd',
|
||||
'curl -sf http://127.0.0.1:18789/healthz',
|
||||
'--health-interval',
|
||||
'30s',
|
||||
'--health-timeout',
|
||||
'10s',
|
||||
'--health-retries',
|
||||
'3',
|
||||
spec.image,
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'gateway',
|
||||
'--bind',
|
||||
'lan',
|
||||
'--port',
|
||||
'18789',
|
||||
'--allow-unconfigured',
|
||||
]
|
||||
}
|
||||
|
||||
describe('ContainerRuntime', () => {
|
||||
it('pullImage runs podman pull for the requested image', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.pullImage('ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
args: ['pull', 'ghcr.io/openclaw/openclaw:2026.4.12'],
|
||||
cwd: PROJECT_DIR,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('startGateway removes any existing gateway and runs a fresh container', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.startGateway(defaultSpec)
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]).toEqual({
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
})
|
||||
expect(calls[1]).toEqual({
|
||||
cwd: PROJECT_DIR,
|
||||
args: expectedStartGatewayRunArgs(defaultSpec),
|
||||
})
|
||||
})
|
||||
|
||||
it('runGatewaySetupCommand in direct mode builds a one-off podman run command', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.runGatewaySetupCommand(
|
||||
['node', 'dist/index.js', 'agents', 'list', '--json'],
|
||||
defaultSpec,
|
||||
)
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: [
|
||||
'rm',
|
||||
'-f',
|
||||
'--ignore',
|
||||
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
|
||||
],
|
||||
},
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: [
|
||||
'run',
|
||||
'--rm',
|
||||
'--name',
|
||||
`${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`,
|
||||
...expectedGatewayRuntimeArgs(defaultSpec),
|
||||
defaultSpec.image,
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'agents',
|
||||
'list',
|
||||
'--json',
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stopGateway removes the direct runtime container', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.stopGateway()
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stopGateway is idempotent when the managed container is already absent', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
options?.onOutput?.(
|
||||
`Error: no container with name "${OPENCLAW_GATEWAY_CONTAINER_NAME}" found`,
|
||||
)
|
||||
return 0
|
||||
})
|
||||
|
||||
await expect(runtime.stopGateway()).resolves.toBeUndefined()
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('getGatewayLogs tails logs from the direct runtime container', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
options?.onOutput?.('first')
|
||||
options?.onOutput?.('second')
|
||||
return 0
|
||||
})
|
||||
|
||||
const logs = await runtime.getGatewayLogs(25)
|
||||
|
||||
expect(logs).toEqual(['first', 'second'])
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['logs', '--tail', '25', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('restartGateway recreates and launches the direct runtime container', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.restartGateway(defaultSpec)
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: ['rm', '-f', '--ignore', OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
},
|
||||
{
|
||||
cwd: PROJECT_DIR,
|
||||
args: expectedStartGatewayRunArgs(defaultSpec),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stopMachineIfSafe allows the managed gateway container', async () => {
|
||||
let stopCalls = 0
|
||||
const runtime = createRuntime(
|
||||
async () => 0,
|
||||
async () => [OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
async () => {
|
||||
stopCalls += 1
|
||||
},
|
||||
)
|
||||
|
||||
await runtime.stopMachineIfSafe()
|
||||
|
||||
expect(stopCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('stopMachineIfSafe does not stop machine if non-BrowserOS containers are running', async () => {
|
||||
let stopCalls = 0
|
||||
const runtime = createRuntime(
|
||||
async () => 0,
|
||||
async () => [OPENCLAW_GATEWAY_CONTAINER_NAME, 'postgres-dev'],
|
||||
async () => {
|
||||
stopCalls += 1
|
||||
},
|
||||
)
|
||||
|
||||
await runtime.stopMachineIfSafe()
|
||||
|
||||
expect(stopCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('execInContainer targets the shared gateway container name', async () => {
|
||||
const calls: Array<{ args: string[]; cwd?: string }> = []
|
||||
const runtime = createRuntime(async (args, options) => {
|
||||
calls.push({ args, cwd: options?.cwd })
|
||||
return 0
|
||||
})
|
||||
|
||||
await runtime.execInContainer(['node', '--version'])
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cwd: undefined,
|
||||
args: ['exec', OPENCLAW_GATEWAY_CONTAINER_NAME, 'node', '--version'],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('tailGatewayLogs targets the shared gateway container name', () => {
|
||||
const names: string[] = []
|
||||
const runtime = new ContainerRuntime(
|
||||
{
|
||||
ensureReady: async () => {},
|
||||
isPodmanAvailable: async () => true,
|
||||
getMachineStatus: async () => ({ initialized: true, running: true }),
|
||||
runCommand: async () => 0,
|
||||
tailContainerLogs: (containerName: string) => {
|
||||
names.push(containerName)
|
||||
return () => {}
|
||||
},
|
||||
listRunningContainers: async () => [],
|
||||
stopMachine: async () => {},
|
||||
} as never,
|
||||
PROJECT_DIR,
|
||||
)
|
||||
|
||||
const stop = runtime.tailGatewayLogs(() => {})
|
||||
stop()
|
||||
|
||||
expect(names).toEqual([OPENCLAW_GATEWAY_CONTAINER_NAME])
|
||||
})
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
resolveComposeResourcePath,
|
||||
SOURCE_COMPOSE_RESOURCE,
|
||||
} from '../../../../src/api/services/openclaw/openclaw-service'
|
||||
|
||||
describe('resolveComposeResourcePath', () => {
|
||||
let tempDir: string | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
tempDir = null
|
||||
}
|
||||
})
|
||||
|
||||
it('prefers the packaged resourcesDir copy when present', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-compose-resource-'))
|
||||
const resourcesDir = join(tempDir, 'resources')
|
||||
const composePath = join(resourcesDir, 'openclaw-compose.yml')
|
||||
await Bun.write(composePath, 'services:\n')
|
||||
|
||||
expect(resolveComposeResourcePath(resourcesDir)).toBe(composePath)
|
||||
})
|
||||
|
||||
it('falls back to the source tree when no packaged copy exists', () => {
|
||||
expect(resolveComposeResourcePath(undefined)).toBe(SOURCE_COMPOSE_RESOURCE)
|
||||
})
|
||||
})
|
||||
@@ -4,25 +4,39 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { buildComposeEnvFile } from '../../../../src/api/services/openclaw/openclaw-env'
|
||||
import { mergeEnvContent } from '../../../../src/api/services/openclaw/openclaw-env'
|
||||
|
||||
describe('buildComposeEnvFile', () => {
|
||||
it('pins the default OpenClaw image to 2026.4.12', () => {
|
||||
describe('mergeEnvContent', () => {
|
||||
it('appends new env keys and normalizes trailing newline', () => {
|
||||
expect(
|
||||
buildComposeEnvFile({
|
||||
hostHome: '/tmp/openclaw-home',
|
||||
timezone: 'UTC',
|
||||
mergeEnvContent('OPENAI_API_KEY=sk-old', {
|
||||
ANTHROPIC_API_KEY: 'ant-key',
|
||||
}),
|
||||
).toContain('OPENCLAW_IMAGE=ghcr.io/openclaw/openclaw:2026.4.12')
|
||||
).toEqual({
|
||||
changed: true,
|
||||
content: 'OPENAI_API_KEY=sk-old\nANTHROPIC_API_KEY=ant-key\n',
|
||||
})
|
||||
})
|
||||
|
||||
it('respects an explicit image override', () => {
|
||||
it('overwrites existing keys when values change', () => {
|
||||
expect(
|
||||
buildComposeEnvFile({
|
||||
hostHome: '/tmp/openclaw-home',
|
||||
timezone: 'UTC',
|
||||
image: 'ghcr.io/openclaw/openclaw:custom',
|
||||
mergeEnvContent('OPENAI_API_KEY=sk-old\n', {
|
||||
OPENAI_API_KEY: 'sk-new',
|
||||
}),
|
||||
).toContain('OPENCLAW_IMAGE=ghcr.io/openclaw/openclaw:custom')
|
||||
).toEqual({
|
||||
changed: true,
|
||||
content: 'OPENAI_API_KEY=sk-new\n',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports unchanged when incoming values match existing content', () => {
|
||||
expect(
|
||||
mergeEnvContent('OPENAI_API_KEY=sk-test\n', {
|
||||
OPENAI_API_KEY: 'sk-test',
|
||||
}),
|
||||
).toEqual({
|
||||
changed: false,
|
||||
content: 'OPENAI_API_KEY=sk-test\n',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,6 +56,7 @@ describe('OpenClawHttpChatClient', () => {
|
||||
agentId: 'research',
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
history: [{ role: 'assistant', content: 'Earlier reply' }],
|
||||
})
|
||||
|
||||
const events = await readEvents(stream)
|
||||
@@ -72,7 +73,10 @@ describe('OpenClawHttpChatClient', () => {
|
||||
expect(JSON.parse(String(call?.[1]?.body))).toEqual({
|
||||
model: 'openclaw/research',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
messages: [
|
||||
{ role: 'assistant', content: 'Earlier reply' },
|
||||
{ role: 'user', content: 'hi' },
|
||||
],
|
||||
user: 'browseros:research:session-123',
|
||||
})
|
||||
expect(events).toEqual([
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
getPodmanOverridesPath,
|
||||
loadPodmanOverrides,
|
||||
savePodmanOverrides,
|
||||
} from '../../../../src/api/services/openclaw/podman-overrides'
|
||||
|
||||
describe('podman overrides', () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browseros-podman-ovr-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns null podmanPath when the overrides file is missing', async () => {
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
})
|
||||
|
||||
it('round-trips save and load', async () => {
|
||||
await savePodmanOverrides(tempDir, {
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({
|
||||
podmanPath: '/opt/homebrew/bin/podman',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when the overrides file is malformed JSON', async () => {
|
||||
fs.writeFileSync(getPodmanOverridesPath(tempDir), '{not json')
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
})
|
||||
|
||||
it('treats empty string and wrong types as null', async () => {
|
||||
fs.writeFileSync(
|
||||
getPodmanOverridesPath(tempDir),
|
||||
JSON.stringify({ podmanPath: '' }),
|
||||
)
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
|
||||
fs.writeFileSync(
|
||||
getPodmanOverridesPath(tempDir),
|
||||
JSON.stringify({ podmanPath: 42 }),
|
||||
)
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
})
|
||||
|
||||
it('persists an explicit null', async () => {
|
||||
await savePodmanOverrides(tempDir, { podmanPath: null })
|
||||
expect(await loadPodmanOverrides(tempDir)).toEqual({ podmanPath: null })
|
||||
expect(fs.existsSync(getPodmanOverridesPath(tempDir))).toBe(true)
|
||||
})
|
||||
|
||||
it('creates the openclaw directory if it does not exist', async () => {
|
||||
const nested = path.join(tempDir, 'does-not-exist')
|
||||
await savePodmanOverrides(nested, { podmanPath: '/usr/local/bin/podman' })
|
||||
expect(fs.existsSync(getPodmanOverridesPath(nested))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -11,9 +11,43 @@ import path from 'node:path'
|
||||
import {
|
||||
configurePodmanRuntime,
|
||||
getPodmanRuntime,
|
||||
PodmanRuntime,
|
||||
resolveBundledPodmanPath,
|
||||
} from '../../../../src/api/services/openclaw/podman-runtime'
|
||||
|
||||
class FakePodmanRuntime extends PodmanRuntime {
|
||||
machineStatuses: Array<{ initialized: boolean; running: boolean }>
|
||||
initCalls = 0
|
||||
startCalls = 0
|
||||
statusCalls = 0
|
||||
|
||||
constructor(statuses: Array<{ initialized: boolean; running: boolean }>) {
|
||||
super({ podmanPath: 'podman' })
|
||||
this.machineStatuses = [...statuses]
|
||||
}
|
||||
|
||||
async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
this.statusCalls += 1
|
||||
return (
|
||||
this.machineStatuses.shift() ?? {
|
||||
initialized: true,
|
||||
running: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async initMachine(): Promise<void> {
|
||||
this.initCalls += 1
|
||||
}
|
||||
|
||||
async startMachine(): Promise<void> {
|
||||
this.startCalls += 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('podman runtime', () => {
|
||||
let tempDir: string
|
||||
|
||||
@@ -80,4 +114,56 @@ describe('podman runtime', () => {
|
||||
|
||||
expect(runtime.getPodmanPath()).toBe('podman')
|
||||
})
|
||||
|
||||
it('ensureReady re-checks machine status on every call', async () => {
|
||||
const runtime = new FakePodmanRuntime([
|
||||
{ initialized: true, running: true },
|
||||
{ initialized: true, running: true },
|
||||
{ initialized: true, running: true },
|
||||
])
|
||||
|
||||
await runtime.ensureReady()
|
||||
await runtime.ensureReady()
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(runtime.statusCalls).toBe(3)
|
||||
expect(runtime.initCalls).toBe(0)
|
||||
expect(runtime.startCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('ensureReady initializes when machine is not present', async () => {
|
||||
const runtime = new FakePodmanRuntime([
|
||||
{ initialized: false, running: false },
|
||||
])
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(runtime.statusCalls).toBe(1)
|
||||
expect(runtime.initCalls).toBe(1)
|
||||
expect(runtime.startCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('ensureReady starts when machine is initialized but stopped', async () => {
|
||||
const runtime = new FakePodmanRuntime([
|
||||
{ initialized: true, running: false },
|
||||
])
|
||||
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(runtime.initCalls).toBe(0)
|
||||
expect(runtime.startCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('ensureReady detects an externally stopped machine on the next call', async () => {
|
||||
const runtime = new FakePodmanRuntime([
|
||||
{ initialized: true, running: true },
|
||||
{ initialized: true, running: false },
|
||||
])
|
||||
|
||||
await runtime.ensureReady()
|
||||
await runtime.ensureReady()
|
||||
|
||||
expect(runtime.statusCalls).toBe(2)
|
||||
expect(runtime.startCalls).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,10 @@ 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'
|
||||
@@ -72,4 +75,34 @@ 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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { appendFile, mkdir, rm } from 'node:fs/promises'
|
||||
import {
|
||||
getLazyMonitoringRunDir,
|
||||
getLazyMonitoringRunsDir,
|
||||
} from '../src/lib/browseros-dir'
|
||||
import {
|
||||
InvalidMonitoringRunIdError,
|
||||
isValidMonitoringRunId,
|
||||
MonitoringStorage,
|
||||
} from '../src/monitoring/storage'
|
||||
|
||||
const createdRunDirs = new Set<string>()
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
[...createdRunDirs].map(async (runId) => {
|
||||
await rm(getLazyMonitoringRunDir(runId), { recursive: true, force: true })
|
||||
}),
|
||||
)
|
||||
createdRunDirs.clear()
|
||||
})
|
||||
|
||||
describe('MonitoringStorage run id validation', () => {
|
||||
it('accepts UUID monitoring run ids', () => {
|
||||
expect(isValidMonitoringRunId('123e4567-e89b-12d3-a456-426614174000')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects path traversal run ids', async () => {
|
||||
expect(isValidMonitoringRunId('../../secret')).toBe(false)
|
||||
|
||||
const storage = new MonitoringStorage()
|
||||
await expect(storage.readContext('../../secret')).rejects.toBeInstanceOf(
|
||||
InvalidMonitoringRunIdError,
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves valid JSONL records when one line is malformed', async () => {
|
||||
const runId = '123e4567-e89b-12d3-a456-426614174001'
|
||||
createdRunDirs.add(runId)
|
||||
|
||||
const storage = new MonitoringStorage()
|
||||
await storage.writeContext({
|
||||
monitoringSessionId: runId,
|
||||
agentId: 'test-agent',
|
||||
sessionKey: 'session-1',
|
||||
originalPrompt: 'Inspect browser state safely',
|
||||
chatHistory: [{ role: 'user', content: 'Inspect browser state safely' }],
|
||||
startedAt: new Date().toISOString(),
|
||||
source: 'debug',
|
||||
})
|
||||
|
||||
await appendFile(
|
||||
`${getLazyMonitoringRunDir(runId)}/tool-calls.jsonl`,
|
||||
[
|
||||
JSON.stringify({
|
||||
monitoringSessionId: runId,
|
||||
agentId: 'test-agent',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'list_windows',
|
||||
source: 'browser-tool',
|
||||
args: {},
|
||||
startedAt: '2026-04-20T15:22:49.817Z',
|
||||
finishedAt: '2026-04-20T15:22:49.818Z',
|
||||
durationMs: 1,
|
||||
}),
|
||||
'{"broken":',
|
||||
JSON.stringify({
|
||||
monitoringSessionId: runId,
|
||||
agentId: 'test-agent',
|
||||
toolCallId: 'tool-2',
|
||||
toolName: 'take_snapshot',
|
||||
source: 'browser-tool',
|
||||
args: {},
|
||||
startedAt: '2026-04-20T15:22:50.817Z',
|
||||
finishedAt: '2026-04-20T15:22:50.818Z',
|
||||
durationMs: 1,
|
||||
}),
|
||||
'',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const toolCalls = await storage.readToolCalls(runId)
|
||||
expect(toolCalls).toHaveLength(2)
|
||||
expect(toolCalls.map((record) => record.toolCallId)).toEqual([
|
||||
'tool-1',
|
||||
'tool-2',
|
||||
])
|
||||
})
|
||||
|
||||
it('skips non-uuid directories when listing run ids', async () => {
|
||||
const validRunId = '123e4567-e89b-12d3-a456-426614174002'
|
||||
createdRunDirs.add(validRunId)
|
||||
|
||||
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
|
||||
await mkdir(getLazyMonitoringRunDir(validRunId), { recursive: true })
|
||||
await mkdir(`${getLazyMonitoringRunsDir()}/not-a-uuid`, {
|
||||
recursive: true,
|
||||
})
|
||||
|
||||
const storage = new MonitoringStorage()
|
||||
const runIds = await storage.listRunIds()
|
||||
|
||||
expect(runIds).toContain(validRunId)
|
||||
expect(runIds).not.toContain('not-a-uuid')
|
||||
|
||||
await rm(`${getLazyMonitoringRunsDir()}/not-a-uuid`, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -232,6 +232,17 @@
|
||||
"zod": "^3.x",
|
||||
},
|
||||
},
|
||||
"packages/build-tools": {
|
||||
"name": "@browseros/build-tools",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.933.0",
|
||||
"@browseros/shared": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.3",
|
||||
},
|
||||
},
|
||||
"packages/cdp-protocol": {
|
||||
"name": "@browseros/cdp-protocol",
|
||||
"version": "0.0.1",
|
||||
@@ -463,6 +474,8 @@
|
||||
|
||||
"@browseros/agent": ["@browseros/agent@workspace:apps/agent"],
|
||||
|
||||
"@browseros/build-tools": ["@browseros/build-tools@workspace:packages/build-tools"],
|
||||
|
||||
"@browseros/cdp-protocol": ["@browseros/cdp-protocol@workspace:packages/cdp-protocol"],
|
||||
|
||||
"@browseros/eval": ["@browseros/eval@workspace:apps/eval"],
|
||||
@@ -4489,6 +4502,8 @@
|
||||
|
||||
"@browseros/agent/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1014.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-node": "^3.972.24", "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", "@aws-sdk/middleware-expect-continue": "^3.972.8", "@aws-sdk/middleware-flexible-checksums": "^3.974.3", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-location-constraint": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/middleware-ssec": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/signature-v4-multi-region": "^3.996.11", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-blob-browser": "^4.2.13", "@smithy/hash-node": "^4.2.12", "@smithy/hash-stream-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/md5-js": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-0XLrOT4Cm3NEhhiME7l/8LbTXS4KdsbR4dSrY207KNKTcHLLTZ9EXt4ZpgnTfLvWQF3pGP2us4Zi1fYLo0N+Ow=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1014.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-node": "^3.972.24", "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", "@aws-sdk/middleware-expect-continue": "^3.972.8", "@aws-sdk/middleware-flexible-checksums": "^3.974.3", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-location-constraint": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/middleware-ssec": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/signature-v4-multi-region": "^3.996.11", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-blob-browser": "^4.2.13", "@smithy/hash-node": "^4.2.12", "@smithy/hash-stream-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/md5-js": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-0XLrOT4Cm3NEhhiME7l/8LbTXS4KdsbR4dSrY207KNKTcHLLTZ9EXt4ZpgnTfLvWQF3pGP2us4Zi1fYLo0N+Ow=="],
|
||||
|
||||
"@browseros/eval/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
@@ -5213,6 +5228,100 @@
|
||||
|
||||
"@browseros/agent/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.24", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-ini": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/crc64-nvme": "^3.972.5", "@aws-sdk/types": "^3.973.6", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-fB7FNLH1+VPUs0QL3PLrHW+DD4gKu6daFgWtyq3R0Y0Lx8DLZPvyGAxCZNFBxH+M2xt9KvBJX6USwjuqvitmCQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-50QgHGPQAb2veqFOmTF1A3GsAklLHZXL47KbY35khIkfbXH5PLvqpEc/gOAEBPj/yFxrlgxz/8mqWcWTNxBkwQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-dLTWy6IfAMhNiSEvMr07g/qZ54be6pLqlxVblbF6AzafmmGAzMMj8qMoY9B4+YgT+gY9IcuxZslNh03L6PyMCQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/config-resolver": "^4.4.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.11", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-SKgZY7x6AloLUXO20FJGnkKJ3a6CXzNDt6PYs2yqoPzgU0xKWcUoGGJGEBTsfM5eihKW42lbwp+sXzACLbSsaA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.10", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-E99zeTscCc+pTMfsvnfi6foPpKmdD1cZfOC7/P8UUrjsoQdg9VEWPRD+xdFduKnfPXwcvby58AlO9jwwF6U96g=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/config-resolver": ["@smithy/config-resolver@4.4.13", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/core": ["@smithy/core@3.23.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.15", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.13", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/hash-node": ["@smithy/hash-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/md5-js": ["@smithy/md5-js@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.27", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-serde": "^4.2.15", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.44", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/service-error-classification": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.15", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.12", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.0", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/protocol-http": ["@smithy/protocol-http@5.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/smithy-client": ["@smithy/smithy-client@4.12.7", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-stack": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/url-parser": ["@smithy/url-parser@4.2.12", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.43", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.47", "", { "dependencies": { "@smithy/config-resolver": "^4.4.13", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-middleware": ["@smithy/util-middleware@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-retry": ["@smithy/util-retry@4.2.12", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-stream": ["@smithy/util-stream@4.5.20", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-waiter": ["@smithy/util-waiter@4.2.13", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.24", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-ini": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw=="],
|
||||
@@ -5597,6 +5706,70 @@
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BkAfKq8Bd4shCtec1usNz//urPJF/SZy14qJyxkSaRJQ/Vv1gVh0VZSTmS7aE6aLMELkFV5wHHrS9ZcdG8Kxsg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-4XZ3+Gu5DY8/n8zQFHBgcKTF7hWQl42G6CY9xfXVo2d25FM/lYkpmuzhYopYoPL1ITWkJ2OSBQfYEu5JRfHOhA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-login": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-PZLSmU0JFpNCDFReidBezsgL5ji9jOBry8CnZdw4Jj6d0K2z3Ftnp44NXgADqYx5BLMu/ZHujfeJReaDoV+IwQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-nRxbeOJ1E1gVA0lNQezuMVndx+ZcuyaW/RB05pUsznN5BxykSlH6KkZ/7Ca/ubJf3i5N3p0gwNO5zgPSCzj+ww=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/token-providers": "3.1014.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-APUccADuYPLL0f2htpM8Z4czabSmHOdo4r41W6lKEZdy++cNJ42Radqy6x4TopENzr3hR6WYMyhiuiqtbf/nAA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-H5JNqtIwOu/feInmMMWcK0dL5r897ReEn7n2m16Dd0DPD9gA2Hg8Cq4UDzZ/9OzaLh/uqBM6seixz0U6Fi2Eag=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-bucket-endpoint/@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-flexible-checksums/@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.5", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3/@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-browser/@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-node/@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/fetch-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-endpoint/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-config-provider/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-config-provider/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-http-handler/@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/url-parser/@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-browser/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-node/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-waiter/@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
|
||||
@@ -5705,6 +5878,22 @@
|
||||
|
||||
"wxt/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1014.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-gHTHNUoaOGNrSWkl32A7wFsU78jlNTlqMccLu0byUk5CysYYXaxNMIonIVr4YcykC7vgtDS5ABuz83giy6fzJA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-browser/@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-node/@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg=="],
|
||||
@@ -5733,6 +5922,8 @@
|
||||
|
||||
"publish-browser-extension/listr2/cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
|
||||
|
||||
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
|
||||
|
||||
"@google/genai/google-auth-library/gaxios/rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
11
packages/browseros-agent/packages/build-tools/.env.sample
Normal file
11
packages/browseros-agent/packages/build-tools/.env.sample
Normal file
@@ -0,0 +1,11 @@
|
||||
# R2 / Cloudflare object storage - required by upload and publish jobs
|
||||
R2_ACCOUNT_ID=
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
R2_BUCKET=browseros
|
||||
|
||||
# Public CDN base - used by cache:sync to GET manifest and artifacts
|
||||
R2_PUBLIC_BASE_URL=https://cdn.browseros.com
|
||||
|
||||
# Dev mode routes cache to ~/.browseros-dev/cache/; unset for ~/.browseros/cache/
|
||||
NODE_ENV=development
|
||||
79
packages/browseros-agent/packages/build-tools/README.md
Normal file
79
packages/browseros-agent/packages/build-tools/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# @browseros/build-tools
|
||||
|
||||
Builds agent image tarballs, publishes release artifacts to R2, and hydrates the local dev cache for agent tarballs.
|
||||
|
||||
The BrowserOS VM is defined by a committed Lima template at `template/browseros-vm.yaml`. There is no custom disk build step; `limactl` consumes the template directly at runtime.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cp packages/build-tools/.env.sample packages/build-tools/.env
|
||||
bun install
|
||||
```
|
||||
|
||||
## Dev loop against the Lima template
|
||||
|
||||
Requires `limactl` on PATH. It is bundled with the server; for bare-worktree use, install Lima with Homebrew.
|
||||
|
||||
```bash
|
||||
brew install lima
|
||||
```
|
||||
|
||||
```bash
|
||||
limactl start \
|
||||
--name browseros-vm-dev \
|
||||
packages/browseros-agent/packages/build-tools/template/browseros-vm.yaml
|
||||
|
||||
limactl shell browseros-vm-dev podman info
|
||||
|
||||
SOCK="$(limactl list browseros-vm-dev --format '{{.Dir}}')/sock/podman.sock"
|
||||
curl --unix-socket "$SOCK" http://d/v5.0.0/libpod/_ping
|
||||
|
||||
bun run --filter @browseros/build-tools build:tarball -- --agent openclaw --arch arm64
|
||||
limactl shell browseros-vm-dev podman load -i "$(ls dist/images/openclaw-*-arm64.tar.gz | head -1)"
|
||||
|
||||
limactl delete --force browseros-vm-dev
|
||||
```
|
||||
|
||||
## Build an agent tarball
|
||||
|
||||
Requires `podman`.
|
||||
|
||||
```bash
|
||||
bun run --filter @browseros/build-tools build:tarball -- --agent openclaw --arch arm64
|
||||
```
|
||||
|
||||
## Smoke test an agent tarball
|
||||
|
||||
```bash
|
||||
bun run --filter @browseros/build-tools smoke:tarball -- --agent openclaw --arch arm64 --tarball ./dist/images/openclaw-2026.4.12-arm64.tar.gz
|
||||
```
|
||||
|
||||
## Emit a manifest
|
||||
|
||||
```bash
|
||||
bun run --filter @browseros/build-tools emit-manifest -- --dist-dir packages/build-tools/dist
|
||||
```
|
||||
|
||||
Publish workflows can update one agent slice at a time. Sliced publishing requires an existing R2 `vm/manifest.json` baseline; bootstrap first releases with `--slice full`.
|
||||
|
||||
```bash
|
||||
bun run --filter @browseros/build-tools emit-manifest -- --slice agents:openclaw --merge-from https://cdn.browseros.com/vm/manifest.json
|
||||
```
|
||||
|
||||
## Sync the dev cache
|
||||
|
||||
```bash
|
||||
NODE_ENV=development bun run --filter @browseros/build-tools cache:sync
|
||||
```
|
||||
|
||||
Pulls the published manifest and tarballs from R2 (`cdn.browseros.com/vm/`). Development cache files land under `~/.browseros-dev/cache/vm/images/`. Production-mode cache files land under `~/.browseros/cache/vm/images/`.
|
||||
|
||||
## Seed the dev cache from a local build
|
||||
|
||||
```bash
|
||||
bun run --filter @browseros/build-tools build:tarball -- --agent openclaw --arch arm64
|
||||
NODE_ENV=development bun run --filter @browseros/build-tools cache:sync:dev
|
||||
```
|
||||
|
||||
`cache:sync:dev` hardcodes `arm64` (all devs are on Apple Silicon), skips R2 entirely, and writes an arm64-only manifest + tarball into `~/.browseros-dev/cache/vm/` from `./dist/`. It refuses to run unless `NODE_ENV=development`. Use this when you want to test the server against a local tarball without publishing.
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"name": "openclaw",
|
||||
"image": "ghcr.io/openclaw/openclaw",
|
||||
"version": "2026.4.12"
|
||||
}
|
||||
]
|
||||
}
|
||||
25
packages/browseros-agent/packages/build-tools/package.json
Normal file
25
packages/browseros-agent/packages/build-tools/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@browseros/build-tools",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "BrowserOS release artifact producer and dev cache sync",
|
||||
"scripts": {
|
||||
"build:tarball": "bun run scripts/build-tarball.ts",
|
||||
"emit-manifest": "bun run scripts/emit-manifest.ts",
|
||||
"upload": "bun run scripts/upload-to-r2.ts",
|
||||
"download": "bun run scripts/download-from-r2.ts",
|
||||
"cache:sync": "bun run scripts/cache-sync.ts",
|
||||
"cache:sync:dev": "bun run scripts/cache-sync-dev.ts",
|
||||
"smoke:tarball": "bun run scripts/smoke-tarball.ts",
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.933.0",
|
||||
"@browseros/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bun
|
||||
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { parseArch, podmanArch } from './common/arch'
|
||||
import { type Bundle, tarballKey } from './common/manifest'
|
||||
import { sha256File } from './common/sha256'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
agent: { type: 'string' },
|
||||
arch: { type: 'string' },
|
||||
'output-dir': { type: 'string', default: './dist/images' },
|
||||
},
|
||||
})
|
||||
|
||||
if (!values.agent || !values.arch) {
|
||||
console.error(
|
||||
'usage: build:tarball -- --agent <name> --arch <arm64|x64> [--output-dir ./dist/images]',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const arch = parseArch(values.arch)
|
||||
const outDir = values['output-dir']
|
||||
await mkdir(outDir, { recursive: true })
|
||||
|
||||
const pkgRoot = path.resolve(import.meta.dir, '..')
|
||||
const bundle = JSON.parse(
|
||||
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
|
||||
) as Bundle
|
||||
const agent = bundle.agents.find(({ name }) => name === values.agent)
|
||||
if (!agent) throw new Error(`unknown agent: ${values.agent}`)
|
||||
|
||||
const ref = `${agent.image}:${agent.version}`
|
||||
const tarballPath = path.join(
|
||||
outDir,
|
||||
path.basename(tarballKey(agent.name, agent.version, arch)),
|
||||
)
|
||||
const tarPath = tarballPath.slice(0, -'.gz'.length)
|
||||
|
||||
await rm(tarballPath, { force: true })
|
||||
await rm(`${tarballPath}.sha256`, { force: true })
|
||||
await rm(tarPath, { force: true })
|
||||
await spawnChecked([
|
||||
'podman',
|
||||
'pull',
|
||||
'--os',
|
||||
'linux',
|
||||
'--arch',
|
||||
podmanArch(arch),
|
||||
ref,
|
||||
])
|
||||
await spawnChecked([
|
||||
'podman',
|
||||
'save',
|
||||
'--format=oci-archive',
|
||||
'--output',
|
||||
tarPath,
|
||||
ref,
|
||||
])
|
||||
await spawnChecked(['gzip', '-9', '-f', tarPath])
|
||||
|
||||
const sha = await sha256File(tarballPath)
|
||||
const size = (await stat(tarballPath)).size
|
||||
await writeFile(
|
||||
`${tarballPath}.sha256`,
|
||||
`${sha} ${path.basename(tarballPath)}\n`,
|
||||
)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
key: tarballKey(agent.name, agent.version, arch),
|
||||
path: tarballPath,
|
||||
sha256: sha,
|
||||
sizeBytes: size,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
async function spawnChecked(argv: string[]): Promise<void> {
|
||||
const proc = Bun.spawn(argv, {
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
})
|
||||
const code = await proc.exited
|
||||
if (code !== 0) throw new Error(`${argv[0]} exited ${code}`)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bun
|
||||
import { copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import type { Arch } from './common/arch'
|
||||
import {
|
||||
type AgentEntry,
|
||||
type AgentManifest,
|
||||
type Bundle,
|
||||
tarballKey,
|
||||
} from './common/manifest'
|
||||
import { sha256File, verifySha256 } from './common/sha256'
|
||||
|
||||
const ARM64: Arch = 'arm64'
|
||||
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
throw new Error(
|
||||
'cache:sync:dev refuses to run without NODE_ENV=development — it writes to ~/.browseros-dev/cache/vm/',
|
||||
)
|
||||
}
|
||||
|
||||
const pkgRoot = path.resolve(import.meta.dir, '..')
|
||||
const distDir = path.join(pkgRoot, 'dist')
|
||||
const bundle = JSON.parse(
|
||||
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
|
||||
) as Bundle
|
||||
|
||||
const cacheRoot = path.join(
|
||||
homedir(),
|
||||
PATHS.DEV_BROWSEROS_DIR_NAME,
|
||||
PATHS.CACHE_DIR_NAME,
|
||||
)
|
||||
const imagesDir = path.join(cacheRoot, 'vm', 'images')
|
||||
const manifestPath = path.join(cacheRoot, 'vm', 'manifest.json')
|
||||
await mkdir(imagesDir, { recursive: true })
|
||||
|
||||
const agents: Record<string, AgentEntry> = {}
|
||||
for (const agent of bundle.agents) {
|
||||
const key = tarballKey(agent.name, agent.version, ARM64)
|
||||
const srcTarball = path.join(distDir, 'images', path.basename(key))
|
||||
await assertExists(srcTarball)
|
||||
|
||||
const sha256 = await sha256File(srcTarball)
|
||||
const sizeBytes = (await stat(srcTarball)).size
|
||||
const destTarball = path.join(cacheRoot, key)
|
||||
|
||||
if (await matchesExisting(destTarball, sha256)) {
|
||||
console.log(`cache hit: ${key}`)
|
||||
} else {
|
||||
await mkdir(path.dirname(destTarball), { recursive: true })
|
||||
await copyFile(srcTarball, destTarball)
|
||||
await verifySha256(destTarball, sha256)
|
||||
console.log(`seeded ${key}`)
|
||||
}
|
||||
|
||||
agents[agent.name] = {
|
||||
image: agent.image,
|
||||
version: agent.version,
|
||||
tarballs: { arm64: { key, sha256, sizeBytes } } as AgentEntry['tarballs'],
|
||||
}
|
||||
}
|
||||
|
||||
const manifest: AgentManifest = {
|
||||
schemaVersion: 2,
|
||||
updatedAt: new Date().toISOString(),
|
||||
agents,
|
||||
}
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
console.log(`manifest written to ${manifestPath}`)
|
||||
|
||||
async function assertExists(filePath: string): Promise<void> {
|
||||
try {
|
||||
await stat(filePath)
|
||||
} catch {
|
||||
throw new Error(
|
||||
`missing ${filePath} — run: bun run build:tarball -- --agent <name> --arch arm64`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function matchesExisting(
|
||||
filePath: string,
|
||||
expectedSha: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await stat(filePath)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return (await sha256File(filePath)) === expectedSha
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env bun
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import { homedir, arch as hostArch } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import { ARCHES, type Arch } from './common/arch'
|
||||
import { fetchWithTimeout } from './common/fetch'
|
||||
import type { AgentManifest, Artifact } from './common/manifest'
|
||||
import { verifySha256 } from './common/sha256'
|
||||
|
||||
type ChunkSink = ReturnType<ReturnType<typeof Bun.file>['writer']>
|
||||
|
||||
export interface PlanItem {
|
||||
key: string
|
||||
destPath: string
|
||||
sha256: string
|
||||
}
|
||||
|
||||
export function planSync(opts: {
|
||||
local: AgentManifest | null
|
||||
remote: AgentManifest
|
||||
cacheRoot: string
|
||||
arches: Arch[]
|
||||
}): PlanItem[] {
|
||||
const out: PlanItem[] = []
|
||||
for (const arch of opts.arches) {
|
||||
for (const [name, agent] of Object.entries(opts.remote.agents)) {
|
||||
maybeAdd(
|
||||
out,
|
||||
agent.tarballs[arch],
|
||||
opts.local?.agents[name]?.tarballs[arch],
|
||||
opts.cacheRoot,
|
||||
)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function selectSyncArches(
|
||||
allArches: boolean,
|
||||
rawHostArch = hostArch(),
|
||||
): Arch[] {
|
||||
if (allArches) return [...ARCHES]
|
||||
if (rawHostArch === 'arm64') return ['arm64']
|
||||
if (rawHostArch === 'x64' || rawHostArch === 'ia32') return ['x64']
|
||||
throw new Error(`unsupported host arch: ${rawHostArch}`)
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
'manifest-url': { type: 'string' },
|
||||
'all-arches': { type: 'boolean' },
|
||||
'cache-dir': { type: 'string' },
|
||||
},
|
||||
})
|
||||
|
||||
const cdnBase =
|
||||
process.env.R2_PUBLIC_BASE_URL?.trim() ?? 'https://cdn.browseros.com'
|
||||
const manifestUrl = values['manifest-url'] ?? `${cdnBase}/vm/manifest.json`
|
||||
const cacheRoot = values['cache-dir'] ?? getCacheDir()
|
||||
const arches = selectSyncArches(values['all-arches'] ?? false)
|
||||
|
||||
const response = await fetchWithTimeout(manifestUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`manifest fetch failed: ${manifestUrl} (${response.status})`,
|
||||
)
|
||||
}
|
||||
const remote = (await response.json()) as AgentManifest
|
||||
|
||||
const localManifestPath = path.join(cacheRoot, 'vm', 'manifest.json')
|
||||
const local = await readLocalManifest(localManifestPath)
|
||||
const plan = planSync({ local, remote, cacheRoot, arches })
|
||||
|
||||
if (plan.length === 0) {
|
||||
console.log('agent cache up to date')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.log(`syncing ${plan.length} agent artifact(s)`)
|
||||
for (const item of plan) {
|
||||
await mkdir(path.dirname(item.destPath), { recursive: true })
|
||||
const partial = `${item.destPath}.partial`
|
||||
await downloadToFile(`${cdnBase}/${item.key}`, partial)
|
||||
await verifySha256(partial, item.sha256)
|
||||
await rename(partial, item.destPath)
|
||||
console.log(`synced ${item.key}`)
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(localManifestPath), { recursive: true })
|
||||
await writeFile(localManifestPath, `${JSON.stringify(remote, null, 2)}\n`)
|
||||
console.log(`manifest written to ${localManifestPath}`)
|
||||
}
|
||||
|
||||
function maybeAdd(
|
||||
out: PlanItem[],
|
||||
remote: Artifact,
|
||||
local: Artifact | undefined,
|
||||
cacheRoot: string,
|
||||
): void {
|
||||
if (local?.sha256 === remote.sha256) return
|
||||
out.push({
|
||||
key: remote.key,
|
||||
destPath: path.join(cacheRoot, remote.key),
|
||||
sha256: remote.sha256,
|
||||
})
|
||||
}
|
||||
|
||||
function getCacheDir(): string {
|
||||
const dirName =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? PATHS.DEV_BROWSEROS_DIR_NAME
|
||||
: PATHS.BROWSEROS_DIR_NAME
|
||||
return path.join(homedir(), dirName, PATHS.CACHE_DIR_NAME)
|
||||
}
|
||||
|
||||
export async function readLocalManifest(
|
||||
manifestPath: string,
|
||||
): Promise<AgentManifest | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(manifestPath, 'utf8')) as AgentManifest
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadToFile(url: string, dest: string): Promise<void> {
|
||||
const response = await fetchWithTimeout(url)
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`download failed: ${url} (${response.status})`)
|
||||
}
|
||||
|
||||
const sink = Bun.file(dest).writer()
|
||||
const reader = response.body.getReader()
|
||||
try {
|
||||
await pumpStream(reader, sink)
|
||||
} finally {
|
||||
await sink.end()
|
||||
}
|
||||
}
|
||||
|
||||
async function pumpStream(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
sink: ChunkSink,
|
||||
): Promise<void> {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
sink.write(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export type Arch = 'arm64' | 'x64'
|
||||
|
||||
export const ARCHES: readonly Arch[] = ['arm64', 'x64']
|
||||
|
||||
export function parseArch(raw: string): Arch {
|
||||
if (raw === 'arm64' || raw === 'x64') return raw
|
||||
throw new Error(`unknown arch: ${raw} (expected arm64|x64)`)
|
||||
}
|
||||
|
||||
export function podmanArch(arch: Arch): 'arm64' | 'amd64' {
|
||||
return arch === 'x64' ? 'amd64' : 'arm64'
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export async function fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs = 30_000,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: init.signal ?? controller.signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if ((error as { name?: string }).name === 'AbortError') {
|
||||
throw new Error(`fetch timed out after ${timeoutMs}ms: ${url}`)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ARCHES, type Arch } from './arch'
|
||||
|
||||
export interface Artifact {
|
||||
key: string
|
||||
sha256: string
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export interface AgentEntry {
|
||||
image: string
|
||||
version: string
|
||||
tarballs: Record<Arch, Artifact>
|
||||
}
|
||||
|
||||
export interface AgentManifest {
|
||||
schemaVersion: 2
|
||||
updatedAt: string
|
||||
agents: Record<string, AgentEntry>
|
||||
}
|
||||
|
||||
export interface BundleAgent {
|
||||
name: string
|
||||
image: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface Bundle {
|
||||
agents: BundleAgent[]
|
||||
}
|
||||
|
||||
export interface ArtifactInput {
|
||||
sha256: string
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export interface ArtifactInputs {
|
||||
agents: Record<string, Record<Arch, ArtifactInput>>
|
||||
}
|
||||
|
||||
export function tarballKey(name: string, version: string, arch: Arch): string {
|
||||
return `vm/images/${name}-${version}-${arch}.tar.gz`
|
||||
}
|
||||
|
||||
export function buildManifest(
|
||||
bundle: Bundle,
|
||||
inputs: ArtifactInputs,
|
||||
now: Date = new Date(),
|
||||
): AgentManifest {
|
||||
const agents: Record<string, AgentEntry> = {}
|
||||
for (const agent of bundle.agents) {
|
||||
const tarballs = {} as Record<Arch, Artifact>
|
||||
for (const arch of ARCHES) {
|
||||
const entry = inputs.agents[agent.name]?.[arch]
|
||||
if (!entry) {
|
||||
throw new Error(`missing tarball inputs for ${agent.name}/${arch}`)
|
||||
}
|
||||
tarballs[arch] = {
|
||||
key: tarballKey(agent.name, agent.version, arch),
|
||||
sha256: entry.sha256,
|
||||
sizeBytes: entry.sizeBytes,
|
||||
}
|
||||
}
|
||||
agents[agent.name] = {
|
||||
image: agent.image,
|
||||
version: agent.version,
|
||||
tarballs,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
updatedAt: now.toISOString(),
|
||||
agents,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import {
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3'
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) throw new Error(`missing env var: ${name}`)
|
||||
return value
|
||||
}
|
||||
|
||||
export function createR2Client(): S3Client {
|
||||
return new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${required('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: required('R2_ACCESS_KEY_ID'),
|
||||
secretAccessKey: required('R2_SECRET_ACCESS_KEY'),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getBucket(): string {
|
||||
return required('R2_BUCKET')
|
||||
}
|
||||
|
||||
export function getCdnBase(): string {
|
||||
return process.env.R2_PUBLIC_BASE_URL?.trim() ?? 'https://cdn.browseros.com'
|
||||
}
|
||||
|
||||
export async function putFile(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
filePath: string,
|
||||
contentType: string,
|
||||
): Promise<void> {
|
||||
const { size } = await stat(filePath)
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: createReadStream(filePath),
|
||||
ContentLength: size,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function putBody(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
body: string,
|
||||
contentType: string,
|
||||
): Promise<void> {
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentLength: Buffer.byteLength(body),
|
||||
ContentType: contentType,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function getBody(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({ Bucket: bucket, Key: key }),
|
||||
)
|
||||
const body = response.Body as
|
||||
| { transformToByteArray(): Promise<Uint8Array> }
|
||||
| undefined
|
||||
if (!body) throw new Error(`missing response body for R2 key: ${key}`)
|
||||
const bytes = await body.transformToByteArray()
|
||||
return new TextDecoder().decode(bytes)
|
||||
} catch (error) {
|
||||
const cause = error as {
|
||||
name?: string
|
||||
$metadata?: { httpStatusCode?: number }
|
||||
}
|
||||
if (cause.name === 'NoSuchKey' || cause.$metadata?.httpStatusCode === 404) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
|
||||
export async function sha256File(path: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(path)) {
|
||||
hash.update(chunk)
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
export async function verifySha256(
|
||||
path: string,
|
||||
expected: string,
|
||||
): Promise<void> {
|
||||
const actual = await sha256File(path)
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`sha256 mismatch for ${path}: expected ${expected}, got ${actual}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/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}`)
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/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
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/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)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/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()
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# BrowserOS VM -- consumed directly by limactl, no build step.
|
||||
# Based on Lima's built-in podman.yaml + _images/debian-12 templates.
|
||||
# https://github.com/lima-vm/lima/tree/master/templates
|
||||
|
||||
minimumLimaVersion: 2.0.0
|
||||
|
||||
vmType: vz
|
||||
cpus: 2
|
||||
memory: 2GiB
|
||||
disk: 10GiB
|
||||
|
||||
# Pinned Debian 12 genericcloud -- matches the deprecated disk pipeline pin.
|
||||
# Bump in lockstep with upstream when provisioning changes.
|
||||
images:
|
||||
- location: "https://cloud.debian.org/images/cloud/bookworm/20260413-2447/debian-12-genericcloud-arm64-20260413-2447.qcow2"
|
||||
arch: aarch64
|
||||
digest: "sha512:15ad6c52e255c84eb0e91001c5907b27199d8a7164d8ac172cfe9c92850dfaf606a6c3161d6af7f0fd5a5fef2aa8dcd9a23c2eb0fedbfcddb38e2bc306cba98f"
|
||||
- location: "https://cloud.debian.org/images/cloud/bookworm/20260413-2447/debian-12-genericcloud-amd64-20260413-2447.qcow2"
|
||||
arch: x86_64
|
||||
digest: "sha512:db11b13c4efcc37828ffadae521d101e85079d349e1418074087bb7d306f11caccdc2b0b539d6fd50d623d40a898f83c6137268a048d7700397dc35b7dcbc927"
|
||||
# Fallbacks for when Debian rotates the dated snapshot.
|
||||
- location: https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-arm64.qcow2
|
||||
arch: aarch64
|
||||
- location: https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2
|
||||
arch: x86_64
|
||||
|
||||
# Host-state isolation -- matches spec D5 / D7 defaults.
|
||||
mounts: []
|
||||
|
||||
# We run podman, not containerd.
|
||||
containerd:
|
||||
system: false
|
||||
user: false
|
||||
|
||||
provision:
|
||||
- mode: system
|
||||
script: |
|
||||
#!/bin/bash
|
||||
set -eux -o pipefail
|
||||
|
||||
if [ -e /etc/browseros-vm-provisioned ]; then exit 0; fi
|
||||
|
||||
DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
podman crun fuse-overlayfs slirp4netns ca-certificates
|
||||
|
||||
systemctl enable --now podman.socket
|
||||
|
||||
# Keep Docker config out of the image pull path (spec D7).
|
||||
mkdir -p /etc/containers
|
||||
containers_auth=/etc/containers/auth
|
||||
printf '{}\n' > "${containers_auth}.json"
|
||||
|
||||
# Single-tenant appliance user (spec D7).
|
||||
id browseros >/dev/null 2>&1 || useradd --create-home --uid 1000 --shell /bin/bash browseros
|
||||
usermod -aG sudo browseros
|
||||
|
||||
# Version marker consumed by the runtime (WS4).
|
||||
printf 'provisioned:%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > /etc/browseros-vm-version
|
||||
|
||||
apt-get clean
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
touch /etc/browseros-vm-provisioned
|
||||
|
||||
# Block `limactl start` until podman is ready.
|
||||
probes:
|
||||
- script: |
|
||||
#!/bin/bash
|
||||
set -eux -o pipefail
|
||||
if ! timeout 60s bash -c 'until systemctl is-active podman.socket >/dev/null; do sleep 2; done'; then
|
||||
echo >&2 "podman.socket not active after 60s"
|
||||
exit 1
|
||||
fi
|
||||
hint: See /var/log/cloud-init-output.log inside the guest
|
||||
|
||||
# Expose guest podman socket to host for the TypeScript runtime to dial.
|
||||
portForwards:
|
||||
- guestSocket: "/run/podman/podman.sock"
|
||||
hostSocket: "{{.Dir}}/sock/podman.sock"
|
||||
@@ -0,0 +1,18 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,263 @@
|
||||
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'
|
||||
|
||||
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('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 }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["scripts/**/*", "tests/**/*", "package.json", "bundle.json"]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export const OPENCLAW_GATEWAY_PORT = 18789
|
||||
export const OPENCLAW_GATEWAY_CONTAINER_PORT = 18789
|
||||
export const OPENCLAW_CONTAINER_HOME = '/home/node/.openclaw'
|
||||
export const OPENCLAW_COMPOSE_PROJECT_NAME = 'browseros-openclaw'
|
||||
export const OPENCLAW_GATEWAY_CONTAINER_NAME = `${OPENCLAW_COMPOSE_PROJECT_NAME}-openclaw-gateway-1`
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
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,142 +1,36 @@
|
||||
{
|
||||
"resources": [
|
||||
{
|
||||
"name": "OpenClaw compose file",
|
||||
"name": "Lima limactl - macOS ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/lima/limactl-darwin-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/lima/limactl",
|
||||
"os": ["macos"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Lima limactl - macOS x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/lima/limactl-darwin-x64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/lima/limactl",
|
||||
"os": ["macos"],
|
||||
"arch": ["x64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "BrowserOS VM Lima template",
|
||||
"source": {
|
||||
"type": "local",
|
||||
"path": "apps/server/resources/openclaw-compose.yml"
|
||||
"path": "packages/build-tools/template/browseros-vm.yaml"
|
||||
},
|
||||
"destination": "resources/openclaw-compose.yml"
|
||||
},
|
||||
{
|
||||
"name": "Podman CLI - macOS ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/podman-darwin-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/podman",
|
||||
"destination": "resources/vm/browseros-vm.yaml",
|
||||
"os": ["macos"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman gvproxy - macOS ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/gvproxy-darwin-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/gvproxy",
|
||||
"os": ["macos"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman vfkit - macOS ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/vfkit-darwin-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/vfkit",
|
||||
"os": ["macos"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman krunkit - macOS ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/krunkit-darwin-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/krunkit",
|
||||
"os": ["macos"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman mac helper - macOS ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/podman-mac-helper-darwin-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/podman-mac-helper",
|
||||
"os": ["macos"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman CLI - macOS x64",
|
||||
"notes": "krunkit is intentionally omitted on macOS x64 because the official amd64 Podman installer ships an arm64-only krunkit helper",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/podman-darwin-x64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/podman",
|
||||
"os": ["macos"],
|
||||
"arch": ["x64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman gvproxy - macOS x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/gvproxy-darwin-x64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/gvproxy",
|
||||
"os": ["macos"],
|
||||
"arch": ["x64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman vfkit - macOS x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/vfkit-darwin-x64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/vfkit",
|
||||
"os": ["macos"],
|
||||
"arch": ["x64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman mac helper - macOS x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/podman-mac-helper-darwin-x64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/podman-mac-helper",
|
||||
"os": ["macos"],
|
||||
"arch": ["x64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman CLI - Windows x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/podman-windows-x64.exe"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/podman.exe",
|
||||
"os": ["windows"],
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"name": "Podman gvproxy - Windows x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/gvproxy-windows-x64.exe"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/gvproxy.exe",
|
||||
"os": ["windows"],
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"name": "Podman win-sshproxy - Windows x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/win-sshproxy-windows-x64.exe"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/win-sshproxy.exe",
|
||||
"os": ["windows"],
|
||||
"arch": ["x64"]
|
||||
"arch": ["arm64", "x64"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { loadManifest } from './manifest'
|
||||
import { stageCompiledArtifact } from './stage'
|
||||
import type { BuildTarget } from './types'
|
||||
|
||||
const TARGET: BuildTarget = {
|
||||
id: 'darwin-arm64',
|
||||
name: 'macOS arm64',
|
||||
os: 'macos',
|
||||
arch: 'arm64',
|
||||
bunTarget: 'bun-darwin-arm64-modern',
|
||||
serverBinaryName: 'browseros-server-darwin-arm64',
|
||||
}
|
||||
|
||||
describe('server artifact staging', () => {
|
||||
let tempDir: string | null = null
|
||||
@@ -25,75 +14,13 @@ describe('server artifact staging', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('loads local resource rules from the manifest', async () => {
|
||||
it('loads empty local-resource rules from the manifest', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'browseros-stage-test-'))
|
||||
const manifestPath = join(tempDir, 'manifest.json')
|
||||
await writeFile(
|
||||
manifestPath,
|
||||
JSON.stringify({
|
||||
resources: [
|
||||
{
|
||||
name: 'OpenClaw compose file',
|
||||
source: {
|
||||
type: 'local',
|
||||
path: 'apps/server/resources/openclaw-compose.yml',
|
||||
},
|
||||
destination: 'resources/openclaw-compose.yml',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
await writeFile(manifestPath, JSON.stringify({ resources: [] }))
|
||||
|
||||
expect(loadManifest(manifestPath)).toEqual({
|
||||
resources: [
|
||||
{
|
||||
name: 'OpenClaw compose file',
|
||||
source: {
|
||||
type: 'local',
|
||||
path: 'apps/server/resources/openclaw-compose.yml',
|
||||
},
|
||||
destination: 'resources/openclaw-compose.yml',
|
||||
executable: false,
|
||||
},
|
||||
],
|
||||
resources: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('copies local resource files into the packaged artifact', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'browseros-stage-test-'))
|
||||
const distRoot = join(tempDir, 'dist')
|
||||
const compiledBinaryPath = join(tempDir, 'browseros-server')
|
||||
const sourceRoot = join(tempDir, 'repo')
|
||||
const composeSourcePath = join(
|
||||
sourceRoot,
|
||||
'apps/server/resources/openclaw-compose.yml',
|
||||
)
|
||||
await writeFile(compiledBinaryPath, '#!/bin/sh\n')
|
||||
await Bun.write(composeSourcePath, 'services:\n')
|
||||
|
||||
const staged = await stageCompiledArtifact(
|
||||
distRoot,
|
||||
compiledBinaryPath,
|
||||
TARGET,
|
||||
'1.2.3',
|
||||
[
|
||||
{
|
||||
name: 'OpenClaw compose file',
|
||||
source: {
|
||||
type: 'local',
|
||||
path: 'apps/server/resources/openclaw-compose.yml',
|
||||
},
|
||||
destination: 'resources/openclaw-compose.yml',
|
||||
},
|
||||
],
|
||||
sourceRoot,
|
||||
)
|
||||
|
||||
expect(
|
||||
await readFile(
|
||||
join(staged.resourcesDir, 'openclaw-compose.yml'),
|
||||
'utf-8',
|
||||
),
|
||||
).toBe('services:\n')
|
||||
})
|
||||
})
|
||||
|
||||
4
packages/browseros/build/browseros.py
generated
4
packages/browseros/build/browseros.py
generated
@@ -44,6 +44,10 @@ app.add_typer(release.app, name="release", help="Release automation")
|
||||
from .cli import ota
|
||||
app.add_typer(ota.app, name="ota", help="OTA update automation")
|
||||
|
||||
# Third-party resource uploads (Lima, future VM disk + agent tarballs)
|
||||
from .cli import storage
|
||||
app.add_typer(storage.app, name="upload", help="Upload third-party resources to R2")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
||||
269
packages/browseros/build/cli/storage.py
generated
Normal file
269
packages/browseros/build/cli/storage.py
generated
Normal file
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Storage CLI - Push third-party resources to R2 for build:server ingestion."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tarfile
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
import typer
|
||||
|
||||
from ..common.env import EnvConfig
|
||||
from ..common.utils import log_error, log_info, log_success, log_warning
|
||||
from ..modules.storage.r2 import (
|
||||
BOTO3_AVAILABLE,
|
||||
get_r2_client,
|
||||
upload_file_to_r2,
|
||||
)
|
||||
|
||||
LIMA_RELEASE_BASE = "https://github.com/lima-vm/lima/releases/download"
|
||||
LIMA_R2_PREFIX = "third_party/lima"
|
||||
LIMA_MANIFEST_KEY = f"{LIMA_R2_PREFIX}/manifest.json"
|
||||
LIMA_HTTP_TIMEOUT_S = 60
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LimaArch:
|
||||
"""Arch-pair: the suffix Lima uses upstream and the suffix we use in R2."""
|
||||
|
||||
internal: str # "arm64" | "x64" — how our R2 keys name it
|
||||
upstream: str # "Darwin-arm64" | "Darwin-x86_64" — Lima's tarball suffix
|
||||
|
||||
|
||||
LIMA_ARCHES: Tuple[LimaArch, ...] = (
|
||||
LimaArch(internal="arm64", upstream="Darwin-arm64"),
|
||||
LimaArch(internal="x64", upstream="Darwin-x86_64"),
|
||||
)
|
||||
|
||||
|
||||
app = typer.Typer(
|
||||
help="Upload third-party resources to Cloudflare R2",
|
||||
pretty_exceptions_enable=False,
|
||||
pretty_exceptions_show_locals=False,
|
||||
)
|
||||
|
||||
|
||||
@app.command("lima")
|
||||
def upload_lima(
|
||||
version: str = typer.Option(
|
||||
...,
|
||||
"--version",
|
||||
"-v",
|
||||
help="Lima release tag, e.g. v1.2.3",
|
||||
),
|
||||
dry_run: bool = typer.Option(
|
||||
False,
|
||||
"--dry-run",
|
||||
help="Download + verify only; skip R2 uploads.",
|
||||
),
|
||||
) -> None:
|
||||
"""Download limactl from a Lima GitHub release and push to R2."""
|
||||
if not BOTO3_AVAILABLE:
|
||||
log_error("boto3 not installed — run: pip install boto3")
|
||||
raise typer.Exit(1)
|
||||
|
||||
env = EnvConfig()
|
||||
if not env.has_r2_config():
|
||||
log_error(
|
||||
"R2 configuration missing. Required: "
|
||||
"R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
tag = _normalize_version_tag(version)
|
||||
client = None if dry_run else get_r2_client(env)
|
||||
if not dry_run and client is None:
|
||||
log_error("Failed to create R2 client")
|
||||
raise typer.Exit(1)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="lima-upload-") as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
checksums = _fetch_checksums(tag, tmp_dir)
|
||||
uploaded_keys: List[str] = []
|
||||
object_shas: Dict[str, str] = {}
|
||||
tarball_shas: Dict[str, str] = {}
|
||||
|
||||
try:
|
||||
for arch in LIMA_ARCHES:
|
||||
tarball_sha, object_sha, r2_key = _process_arch(
|
||||
tag, arch, tmp_dir, checksums, client, env, dry_run
|
||||
)
|
||||
tarball_shas[arch.internal] = tarball_sha
|
||||
object_shas[arch.internal] = object_sha
|
||||
if not dry_run:
|
||||
uploaded_keys.append(r2_key)
|
||||
|
||||
manifest = _build_manifest(tag, tarball_shas, object_shas)
|
||||
_upload_manifest(client, env, manifest, tmp_dir, dry_run)
|
||||
# Any failure mid-loop (download, sha verify, extract, upload) must
|
||||
# roll back prior arch uploads so R2 never holds a mixed-version pair.
|
||||
except Exception as exc:
|
||||
if not dry_run and uploaded_keys:
|
||||
log_warning(f"Upload failed — rolling back {len(uploaded_keys)} object(s)")
|
||||
_rollback(client, env.r2_bucket, uploaded_keys)
|
||||
log_error(f"Lima upload aborted: {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
log_success(f"Lima {tag} uploaded for {[a.internal for a in LIMA_ARCHES]}")
|
||||
|
||||
|
||||
def _normalize_version_tag(version: str) -> str:
|
||||
return version if version.startswith("v") else f"v{version}"
|
||||
|
||||
|
||||
def _fetch_checksums(tag: str, tmp_dir: Path) -> Dict[str, str]:
|
||||
url = f"{LIMA_RELEASE_BASE}/{tag}/SHA256SUMS"
|
||||
dest = tmp_dir / "SHA256SUMS"
|
||||
log_info(f"Fetching {url}")
|
||||
_download(url, dest)
|
||||
return _parse_checksums(dest.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _parse_checksums(contents: str) -> Dict[str, str]:
|
||||
"""Parse lines like '<sha256> lima-1.2.3-Darwin-arm64.tar.gz'."""
|
||||
entries: Dict[str, str] = {}
|
||||
for raw_line in contents.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) != 2:
|
||||
raise RuntimeError(f"Malformed SHA256SUMS line: {raw_line!r}")
|
||||
sha, name = parts[0].lower(), parts[1].lstrip("*").strip()
|
||||
if len(sha) != 64 or not all(c in "0123456789abcdef" for c in sha):
|
||||
raise RuntimeError(f"Invalid sha256 in SHA256SUMS: {raw_line!r}")
|
||||
entries[name] = sha
|
||||
return entries
|
||||
|
||||
|
||||
def _process_arch(
|
||||
tag: str,
|
||||
arch: LimaArch,
|
||||
tmp_dir: Path,
|
||||
checksums: Dict[str, str],
|
||||
client: Any,
|
||||
env: EnvConfig,
|
||||
dry_run: bool,
|
||||
) -> Tuple[str, str, str]:
|
||||
version_num = tag.lstrip("v")
|
||||
tarball_name = f"lima-{version_num}-{arch.upstream}.tar.gz"
|
||||
expected_sha = checksums.get(tarball_name)
|
||||
if not expected_sha:
|
||||
raise RuntimeError(
|
||||
f"{tarball_name} missing from SHA256SUMS (is the version tag correct?)"
|
||||
)
|
||||
|
||||
tarball_path = tmp_dir / tarball_name
|
||||
url = f"{LIMA_RELEASE_BASE}/{tag}/{tarball_name}"
|
||||
log_info(f"Downloading {url}")
|
||||
_download(url, tarball_path)
|
||||
|
||||
actual_sha = _sha256_file(tarball_path)
|
||||
if actual_sha != expected_sha:
|
||||
raise RuntimeError(
|
||||
f"sha256 mismatch for {tarball_name}: "
|
||||
f"expected {expected_sha}, got {actual_sha}"
|
||||
)
|
||||
|
||||
limactl_path = tmp_dir / f"limactl-darwin-{arch.internal}"
|
||||
_extract_limactl(tarball_path, limactl_path)
|
||||
object_sha = _sha256_file(limactl_path)
|
||||
|
||||
r2_key = f"{LIMA_R2_PREFIX}/limactl-darwin-{arch.internal}"
|
||||
if dry_run:
|
||||
log_info(f"[dry-run] skipped upload of {r2_key}")
|
||||
else:
|
||||
if not upload_file_to_r2(client, limactl_path, r2_key, env.r2_bucket):
|
||||
raise RuntimeError(f"Failed to upload {r2_key}")
|
||||
|
||||
return actual_sha, object_sha, r2_key
|
||||
|
||||
|
||||
def _extract_limactl(tarball_path: Path, dest: Path) -> None:
|
||||
"""Extract the single `bin/limactl` entry to dest."""
|
||||
with tarfile.open(tarball_path, "r:gz") as tar:
|
||||
member = _find_limactl_member(tar)
|
||||
extracted = tar.extractfile(member)
|
||||
if extracted is None:
|
||||
raise RuntimeError(f"{member.name} is not a regular file")
|
||||
with extracted as src, open(dest, "wb") as out:
|
||||
while chunk := src.read(1024 * 1024):
|
||||
out.write(chunk)
|
||||
dest.chmod(0o755)
|
||||
|
||||
|
||||
def _find_limactl_member(tar: tarfile.TarFile) -> tarfile.TarInfo:
|
||||
for member in tar.getmembers():
|
||||
if not member.isfile():
|
||||
continue
|
||||
parts = Path(member.name).parts
|
||||
if len(parts) >= 2 and parts[-2:] == ("bin", "limactl"):
|
||||
return member
|
||||
raise RuntimeError("bin/limactl not found in Lima tarball")
|
||||
|
||||
|
||||
def _build_manifest(
|
||||
tag: str,
|
||||
tarball_shas: Dict[str, str],
|
||||
object_shas: Dict[str, str],
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"lima_version": tag,
|
||||
"tarball_shas_upstream": tarball_shas,
|
||||
"r2_object_shas": object_shas,
|
||||
"uploaded_at": datetime.now(timezone.utc).isoformat(),
|
||||
# Prefer CI context so we don't leak an individual's OS login when
|
||||
# running locally. manifest.json is surfaced via the public CDN.
|
||||
"uploaded_by": os.environ.get("GITHUB_ACTOR") or "local",
|
||||
}
|
||||
|
||||
|
||||
def _upload_manifest(
|
||||
client: Any,
|
||||
env: EnvConfig,
|
||||
manifest: Dict[str, Any],
|
||||
tmp_dir: Path,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
manifest_path = tmp_dir / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
if dry_run:
|
||||
log_info(f"[dry-run] manifest would be: {manifest}")
|
||||
return
|
||||
if not upload_file_to_r2(client, manifest_path, LIMA_MANIFEST_KEY, env.r2_bucket):
|
||||
raise RuntimeError(f"Failed to upload {LIMA_MANIFEST_KEY}")
|
||||
|
||||
|
||||
def _rollback(client: Any, bucket: str, keys: List[str]) -> None:
|
||||
for key in keys:
|
||||
try:
|
||||
client.delete_object(Bucket=bucket, Key=key)
|
||||
log_info(f"Rolled back {key}")
|
||||
except Exception as exc:
|
||||
log_warning(f"Rollback failed for {key}: {exc}")
|
||||
|
||||
|
||||
def _download(url: str, dest: Path, *, timeout: Optional[int] = None) -> None:
|
||||
response = requests.get(url, stream=True, timeout=timeout or LIMA_HTTP_TIMEOUT_S)
|
||||
response.raise_for_status()
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(dest, "wb") as out:
|
||||
for chunk in response.iter_content(chunk_size=1024 * 1024):
|
||||
if chunk:
|
||||
out.write(chunk)
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
sha = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
sha.update(chunk)
|
||||
return sha.hexdigest()
|
||||
246
packages/browseros/build/cli/storage_test.py
generated
Normal file
246
packages/browseros/build/cli/storage_test.py
generated
Normal file
@@ -0,0 +1,246 @@
|
||||
#!/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,26 +30,17 @@ MACOS_SERVER_BINARIES: Dict[str, SignSpec] = {
|
||||
),
|
||||
"bun": SignSpec("bun", "runtime", "browseros-executable-entitlements.plist"),
|
||||
"rg": SignSpec("rg", "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"),
|
||||
"limactl": SignSpec("limactl", "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., ``podman-mac-helper``)."""
|
||||
"""Look up sign metadata by file stem (e.g., ``limactl``)."""
|
||||
return MACOS_SERVER_BINARIES.get(binary_path.stem)
|
||||
|
||||
|
||||
|
||||
@@ -28,14 +28,17 @@ 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/podman-mac-helper"))
|
||||
spec = macos_sign_spec_for(Path("/x/limactl"))
|
||||
assert spec is not None
|
||||
self.assertEqual(spec.identifier_suffix, "podman_mac_helper")
|
||||
self.assertEqual(spec.identifier_suffix, "limactl")
|
||||
self.assertIsNone(macos_sign_spec_for(Path("/x/not_a_known_binary")))
|
||||
|
||||
def test_matches_podman_bundle_layout(self):
|
||||
required = {"podman", "gvproxy", "vfkit", "krunkit", "podman-mac-helper"}
|
||||
self.assertTrue(required.issubset(MACOS_SERVER_BINARIES.keys()))
|
||||
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}")
|
||||
|
||||
|
||||
class WindowsServerBinariesTest(unittest.TestCase):
|
||||
@@ -58,6 +61,17 @@ 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/podman/podman`` — mirroring what the agent
|
||||
``resources/bin/third_party/lima/limactl`` — 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.
|
||||
"""
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,12 +0,0 @@
|
||||
<?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