mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 16:14:28 +00:00
Compare commits
9 Commits
fix/podman
...
fix/cache-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f504702ec | ||
|
|
c656f6236c | ||
|
|
4d660874ad | ||
|
|
819887a2c5 | ||
|
|
114d5e3a9f | ||
|
|
ecba7de221 | ||
|
|
123a13fe62 | ||
|
|
5ccdbaf87f | ||
|
|
0650f21c80 |
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.
|
||||
|
||||
@@ -39,6 +39,7 @@ import { AgentTerminal } from './AgentTerminal'
|
||||
import { getOpenClawSupportedProviders } from './openclaw-supported-providers'
|
||||
import {
|
||||
type AgentEntry,
|
||||
type GatewayLifecycleAction,
|
||||
type OpenClawStatus,
|
||||
useOpenClawAgents,
|
||||
useOpenClawMutations,
|
||||
@@ -46,6 +47,14 @@ import {
|
||||
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'],
|
||||
{
|
||||
@@ -229,17 +238,13 @@ const ProviderSelector: FC<ProviderSelectorProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
interface PodmanOverridesCardProps {
|
||||
variant: 'inline' | 'standalone'
|
||||
}
|
||||
|
||||
const PodmanOverridesCard: FC<PodmanOverridesCardProps> = ({ variant }) => {
|
||||
const PodmanOverridesCard: FC = () => {
|
||||
const { overrides, loading, saving, error, saveOverrides, clearOverrides } =
|
||||
usePodmanOverrides()
|
||||
|
||||
const [value, setValue] = useState('')
|
||||
const [touched, setTouched] = useState(false)
|
||||
const [collapsed, setCollapsed] = useState(variant === 'standalone')
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -276,14 +281,11 @@ const PodmanOverridesCard: FC<PodmanOverridesCardProps> = ({ variant }) => {
|
||||
const body = (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor={`podman-path-${variant}`}
|
||||
className="font-medium text-sm"
|
||||
>
|
||||
<label htmlFor="podman-path" className="font-medium text-sm">
|
||||
Podman binary path
|
||||
</label>
|
||||
<Input
|
||||
id={`podman-path-${variant}`}
|
||||
id="podman-path"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setTouched(true)
|
||||
@@ -332,15 +334,6 @@ const PodmanOverridesCard: FC<PodmanOverridesCardProps> = ({ variant }) => {
|
||||
</div>
|
||||
)
|
||||
|
||||
if (variant === 'inline') {
|
||||
return (
|
||||
<div className="mt-3 rounded-md border bg-background p-3">
|
||||
<p className="mb-2 font-medium text-sm">Use your own Podman</p>
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
@@ -388,6 +381,7 @@ export const AgentsPage: FC = () => {
|
||||
creating,
|
||||
deleting,
|
||||
reconnecting,
|
||||
pendingGatewayAction,
|
||||
} = useOpenClawMutations()
|
||||
|
||||
const [setupOpen, setSetupOpen] = useState(false)
|
||||
@@ -424,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) {
|
||||
@@ -454,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)
|
||||
@@ -617,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
|
||||
@@ -628,6 +631,13 @@ export const AgentsPage: FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lifecycleBanner && (
|
||||
<Alert>
|
||||
<Loader2 className="animate-spin" />
|
||||
<AlertTitle>{lifecycleBanner}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{inlineError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle />
|
||||
@@ -647,7 +657,7 @@ export const AgentsPage: FC = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status && gatewayUiState.controlPlaneDegraded && (
|
||||
{status && showControlPlaneDegraded && (
|
||||
<Alert
|
||||
variant={
|
||||
status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'
|
||||
@@ -687,7 +697,6 @@ export const AgentsPage: FC = () => {
|
||||
Restart Gateway
|
||||
</Button>
|
||||
</div>
|
||||
<PodmanOverridesCard variant="inline" />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -707,9 +716,6 @@ export const AgentsPage: FC = () => {
|
||||
{status.podmanAvailable && (
|
||||
<Button onClick={() => setSetupOpen(true)}>Set Up Now</Button>
|
||||
)}
|
||||
<div className="w-full max-w-md">
|
||||
<PodmanOverridesCard variant="inline" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -753,9 +759,6 @@ export const AgentsPage: FC = () => {
|
||||
Restart Gateway
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-full max-w-md">
|
||||
<PodmanOverridesCard variant="inline" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -775,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
|
||||
@@ -804,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
|
||||
@@ -814,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>
|
||||
@@ -827,7 +830,7 @@ export const AgentsPage: FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PodmanOverridesCard variant="standalone" />
|
||||
<PodmanOverridesCard />
|
||||
|
||||
<Dialog open={setupOpen} onOpenChange={setSetupOpen}>
|
||||
<DialogContent>
|
||||
@@ -898,7 +901,7 @@ export const AgentsPage: FC = () => {
|
||||
disabled={
|
||||
!newName.trim() ||
|
||||
creating ||
|
||||
!gatewayUiState.canManageAgents ||
|
||||
!canManageAgents ||
|
||||
compatibleProviders.length === 0
|
||||
}
|
||||
className="w-full"
|
||||
|
||||
@@ -67,6 +67,13 @@ export interface PodmanOverrides {
|
||||
effectivePodmanPath: string
|
||||
}
|
||||
|
||||
export type GatewayLifecycleAction =
|
||||
| 'setup'
|
||||
| 'start'
|
||||
| 'stop'
|
||||
| 'restart'
|
||||
| 'reconnect'
|
||||
|
||||
async function clawFetch<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
@@ -224,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,
|
||||
@@ -244,6 +258,7 @@ export function useOpenClawMutations() {
|
||||
creating: createMutation.isPending,
|
||||
deleting: deleteMutation.isPending,
|
||||
reconnecting: reconnectMutation.isPending,
|
||||
pendingGatewayAction,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
import { accessSync, existsSync, constants as fsConstants } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { OPENCLAW_GATEWAY_PORT } from '@browseros/shared/constants/openclaw'
|
||||
import { Hono } from 'hono'
|
||||
import { stream } from 'hono/streaming'
|
||||
import { logger } from '../../lib/logger'
|
||||
@@ -83,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,
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
* OpenClaw container lifecycle abstraction over PodmanRuntime.
|
||||
*/
|
||||
|
||||
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
|
||||
import {
|
||||
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'
|
||||
|
||||
@@ -15,7 +18,7 @@ const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
|
||||
|
||||
export type GatewayContainerSpec = {
|
||||
image: string
|
||||
port: number
|
||||
hostPort: number
|
||||
hostHome: string
|
||||
envFilePath: string
|
||||
gatewayToken?: string
|
||||
@@ -54,6 +57,7 @@ export class ContainerRuntime {
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
await this.ensureGatewayRemoved(onLog)
|
||||
const containerPort = String(OPENCLAW_GATEWAY_CONTAINER_PORT)
|
||||
const code = await this.runPodmanCommand(
|
||||
[
|
||||
'run',
|
||||
@@ -63,10 +67,10 @@ export class ContainerRuntime {
|
||||
'--restart',
|
||||
'unless-stopped',
|
||||
'-p',
|
||||
`127.0.0.1:${input.port}:18789`,
|
||||
`127.0.0.1:${input.hostPort}:${containerPort}`,
|
||||
...this.buildGatewayContainerRuntimeArgs(input),
|
||||
'--health-cmd',
|
||||
'curl -sf http://127.0.0.1:18789/healthz',
|
||||
`curl -sf http://127.0.0.1:${containerPort}/healthz`,
|
||||
'--health-interval',
|
||||
'30s',
|
||||
'--health-timeout',
|
||||
@@ -80,7 +84,7 @@ export class ContainerRuntime {
|
||||
'--bind',
|
||||
'lan',
|
||||
'--port',
|
||||
'18789',
|
||||
containerPort,
|
||||
'--allow-unconfigured',
|
||||
],
|
||||
onLog,
|
||||
@@ -111,31 +115,34 @@ export class ContainerRuntime {
|
||||
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
|
||||
@@ -143,7 +150,7 @@ export class ContainerRuntime {
|
||||
await Bun.sleep(1000)
|
||||
}
|
||||
logger.error('Timed out waiting for OpenClaw gateway readiness', {
|
||||
port,
|
||||
hostPort,
|
||||
timeoutMs,
|
||||
})
|
||||
return false
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface OpenClawChatRequest {
|
||||
|
||||
export class OpenClawHttpChatClient {
|
||||
constructor(
|
||||
private readonly port: number,
|
||||
private readonly hostPort: number,
|
||||
private readonly getToken: () => Promise<string>,
|
||||
) {}
|
||||
|
||||
@@ -42,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: {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
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'
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import type { OpenClawStreamEvent } from './openclaw-types'
|
||||
import { loadPodmanOverrides, savePodmanOverrides } from './podman-overrides'
|
||||
import { configurePodmanRuntime, getPodmanRuntime } from './podman-runtime'
|
||||
import { allocateGatewayPort, readPersistedGatewayPort } from './runtime-state'
|
||||
|
||||
const READY_TIMEOUT_MS = 30_000
|
||||
const AGENT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/
|
||||
@@ -120,7 +121,7 @@ export class OpenClawService {
|
||||
private bootstrapCliClient: OpenClawCliClient
|
||||
private chatClient: OpenClawHttpChatClient
|
||||
private openclawDir: string
|
||||
private port = OPENCLAW_GATEWAY_PORT
|
||||
private hostPort = OPENCLAW_GATEWAY_CONTAINER_PORT
|
||||
private token: string
|
||||
private tokenLoaded = false
|
||||
private lastError: string | null = null
|
||||
@@ -130,6 +131,7 @@ export class OpenClawService {
|
||||
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()
|
||||
@@ -138,7 +140,7 @@ export class OpenClawService {
|
||||
this.cliClient = new OpenClawCliClient(this.runtime)
|
||||
this.bootstrapCliClient = this.buildBootstrapCliClient()
|
||||
this.chatClient = new OpenClawHttpChatClient(
|
||||
this.port,
|
||||
this.hostPort,
|
||||
async () => this.token,
|
||||
)
|
||||
this.browserosServerPort =
|
||||
@@ -155,200 +157,257 @@ export class OpenClawService {
|
||||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
|
||||
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')
|
||||
|
||||
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()
|
||||
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)
|
||||
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.getGatewayLogs()
|
||||
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()
|
||||
|
||||
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.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.stopGateway()
|
||||
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('Refreshing gateway auth token...')
|
||||
this.tokenLoaded = false
|
||||
await this.loadTokenFromConfig()
|
||||
await this.ensureStateEnvFile()
|
||||
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.port, 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', { 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> {
|
||||
@@ -399,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
|
||||
@@ -418,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',
|
||||
@@ -618,41 +677,49 @@ export class OpenClawService {
|
||||
// ── 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()
|
||||
|
||||
if (!(await this.runtime.isReady(this.port))) {
|
||||
await this.runtime.startGateway(this.buildGatewayRuntimeSpec())
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
@@ -675,10 +742,50 @@ export class OpenClawService {
|
||||
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,
|
||||
})
|
||||
@@ -782,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}`,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -893,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)
|
||||
@@ -929,7 +1039,7 @@ export class OpenClawService {
|
||||
private buildGatewayRuntimeSpec(): GatewayContainerSpec {
|
||||
return {
|
||||
image: this.getGatewayImage(),
|
||||
port: this.port,
|
||||
hostPort: this.hostPort,
|
||||
hostHome: this.openclawDir,
|
||||
envFilePath: this.getStateEnvPath(),
|
||||
gatewayToken: this.tokenLoaded ? this.token : undefined,
|
||||
@@ -1080,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
|
||||
|
||||
@@ -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,18 @@ 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')
|
||||
}
|
||||
@@ -86,6 +96,7 @@ export async function ensureBrowserosDir(): Promise<void> {
|
||||
await mkdir(getBuiltinSkillsDir(), { recursive: true })
|
||||
await mkdir(getSessionsDir(), { recursive: true })
|
||||
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
|
||||
await mkdir(getAgentCacheDir(), { recursive: true })
|
||||
}
|
||||
|
||||
export async function cleanOldSessions(): Promise<void> {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ContainerRuntime } from '../../../../src/api/services/openclaw/containe
|
||||
const PROJECT_DIR = '/tmp/openclaw'
|
||||
const defaultSpec = {
|
||||
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
port: 18789,
|
||||
hostPort: 18789,
|
||||
hostHome: '/tmp/openclaw',
|
||||
envFilePath: '/tmp/openclaw/.openclaw/.env',
|
||||
gatewayToken: 'token-123',
|
||||
@@ -75,7 +75,7 @@ function expectedStartGatewayRunArgs(spec: typeof defaultSpec): string[] {
|
||||
'--restart',
|
||||
'unless-stopped',
|
||||
'-p',
|
||||
`127.0.0.1:${spec.port}:18789`,
|
||||
`127.0.0.1:${spec.hostPort}:18789`,
|
||||
...expectedGatewayRuntimeArgs(spec),
|
||||
'--health-cmd',
|
||||
'curl -sf http://127.0.0.1:18789/healthz',
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { createServer } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
|
||||
@@ -23,7 +24,8 @@ type MutableOpenClawService = OpenClawService & {
|
||||
ensureReady?: () => Promise<void>
|
||||
isPodmanAvailable?: () => Promise<boolean>
|
||||
getMachineStatus?: () => Promise<{ initialized: boolean; running: boolean }>
|
||||
isReady: () => Promise<boolean>
|
||||
isHealthy?: (_hostPort?: number) => Promise<boolean>
|
||||
isReady: (_hostPort?: number) => Promise<boolean>
|
||||
pullImage?: (
|
||||
_image: string,
|
||||
_onLog?: (_line: string) => void,
|
||||
@@ -292,7 +294,7 @@ describe('OpenClawService', () => {
|
||||
expect(startGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
port: 18789,
|
||||
hostPort: expect.any(Number),
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: undefined,
|
||||
@@ -573,7 +575,7 @@ describe('OpenClawService', () => {
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady: async () => true,
|
||||
isReady: async () => false,
|
||||
startGateway,
|
||||
waitForReady,
|
||||
}
|
||||
@@ -587,7 +589,7 @@ describe('OpenClawService', () => {
|
||||
expect(startGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
port: 18789,
|
||||
hostPort: expect.any(Number),
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: 'cli-token',
|
||||
@@ -598,6 +600,99 @@ describe('OpenClawService', () => {
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('serializes concurrent start calls and only starts the gateway once', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: 'cli-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
let gatewayReady = false
|
||||
let releaseStartGateway!: () => void
|
||||
let notifyStartGatewayEntered!: () => void
|
||||
const startGatewayEntered = new Promise<void>((resolve) => {
|
||||
notifyStartGatewayEntered = resolve
|
||||
})
|
||||
const unblockStartGateway = new Promise<void>((resolve) => {
|
||||
releaseStartGateway = resolve
|
||||
})
|
||||
const ensureReady = mock(async () => {})
|
||||
const startGateway = mock(async () => {
|
||||
notifyStartGatewayEntered()
|
||||
await unblockStartGateway
|
||||
gatewayReady = true
|
||||
})
|
||||
const waitForReady = mock(async () => true)
|
||||
const probe = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady: async () => gatewayReady,
|
||||
startGateway,
|
||||
waitForReady,
|
||||
}
|
||||
service.cliClient = {
|
||||
probe,
|
||||
}
|
||||
|
||||
const firstStart = service.start()
|
||||
await startGatewayEntered
|
||||
const secondStart = service.start()
|
||||
releaseStartGateway()
|
||||
await Promise.all([firstStart, secondStart])
|
||||
|
||||
expect(ensureReady).toHaveBeenCalledTimes(2)
|
||||
expect(startGateway).toHaveBeenCalledTimes(1)
|
||||
expect(waitForReady).toHaveBeenCalledTimes(1)
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not restart a ready gateway when start is called again', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: 'cli-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const ensureReady = mock(async () => {})
|
||||
const startGateway = mock(async () => {})
|
||||
const waitForReady = mock(async () => true)
|
||||
const probe = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
ensureReady,
|
||||
isReady: async () => true,
|
||||
startGateway,
|
||||
waitForReady,
|
||||
}
|
||||
service.cliClient = {
|
||||
probe,
|
||||
}
|
||||
|
||||
await service.start()
|
||||
|
||||
expect(ensureReady).toHaveBeenCalledTimes(1)
|
||||
expect(startGateway).not.toHaveBeenCalled()
|
||||
expect(waitForReady).not.toHaveBeenCalled()
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('restart uses the direct runtime restartGateway flow', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
@@ -631,7 +726,7 @@ describe('OpenClawService', () => {
|
||||
expect(restartGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
port: 18789,
|
||||
hostPort: expect.any(Number),
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: 'cli-token',
|
||||
@@ -642,6 +737,72 @@ describe('OpenClawService', () => {
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('restart keeps the persisted gateway port when the current gateway already owns it', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
|
||||
await mkdir(join(tempDir, '.openclaw'), { recursive: true })
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'openclaw.json'),
|
||||
JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: 'cli-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const occupiedServer = createServer()
|
||||
const occupiedPort = await new Promise<number>((resolve, reject) => {
|
||||
occupiedServer.once('error', reject)
|
||||
occupiedServer.listen(0, '127.0.0.1', () => {
|
||||
const address = occupiedServer.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
reject(new Error('failed to allocate test port'))
|
||||
return
|
||||
}
|
||||
resolve(address.port)
|
||||
})
|
||||
})
|
||||
await writeFile(
|
||||
join(tempDir, '.openclaw', 'runtime-state.json'),
|
||||
`${JSON.stringify({ gatewayPort: occupiedPort }, null, 2)}\n`,
|
||||
)
|
||||
const restartGateway = mock(async () => {})
|
||||
const waitForReady = mock(async () => true)
|
||||
const probe = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
|
||||
service.openclawDir = tempDir
|
||||
service.runtime = {
|
||||
isReady: async (hostPort?: number) => hostPort === occupiedPort,
|
||||
restartGateway,
|
||||
waitForReady,
|
||||
}
|
||||
service.cliClient = {
|
||||
probe,
|
||||
}
|
||||
|
||||
try {
|
||||
await service.restart()
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
occupiedServer.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
expect(restartGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hostPort: occupiedPort,
|
||||
}),
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
|
||||
it('stop calls runtime.stopGateway', async () => {
|
||||
const stopGateway = mock(async () => {})
|
||||
const service = new OpenClawService() as MutableOpenClawService
|
||||
@@ -743,7 +904,7 @@ describe('OpenClawService', () => {
|
||||
expect(startGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
|
||||
port: 18789,
|
||||
hostPort: expect.any(Number),
|
||||
hostHome: tempDir,
|
||||
envFilePath: join(tempDir, '.openclaw', '.env'),
|
||||
gatewayToken: 'cli-token',
|
||||
@@ -751,7 +912,7 @@ describe('OpenClawService', () => {
|
||||
)
|
||||
expect(waitForReady).toHaveBeenCalledTimes(1)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
expect(isReady).toHaveBeenCalledTimes(1)
|
||||
expect(isReady).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps openrouter model refs verbatim without rewriting dots', () => {
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,134 +1,36 @@
|
||||
{
|
||||
"resources": [
|
||||
{
|
||||
"name": "Podman CLI - macOS ARM64",
|
||||
"name": "Lima limactl - macOS ARM64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/podman-darwin-arm64"
|
||||
"key": "third_party/lima/limactl-darwin-arm64"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/podman",
|
||||
"destination": "resources/bin/third_party/lima/limactl",
|
||||
"os": ["macos"],
|
||||
"arch": ["arm64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman gvproxy - macOS ARM64",
|
||||
"name": "Lima limactl - macOS x64",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/gvproxy-darwin-arm64"
|
||||
"key": "third_party/lima/limactl-darwin-x64"
|
||||
},
|
||||
"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",
|
||||
"destination": "resources/bin/third_party/lima/limactl",
|
||||
"os": ["macos"],
|
||||
"arch": ["x64"],
|
||||
"executable": true
|
||||
},
|
||||
{
|
||||
"name": "Podman gvproxy - macOS x64",
|
||||
"name": "BrowserOS VM Lima template",
|
||||
"source": {
|
||||
"type": "r2",
|
||||
"key": "third_party/podman/gvproxy-darwin-x64"
|
||||
"type": "local",
|
||||
"path": "packages/build-tools/template/browseros-vm.yaml"
|
||||
},
|
||||
"destination": "resources/bin/third_party/podman/gvproxy",
|
||||
"destination": "resources/vm/browseros-vm.yaml",
|
||||
"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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
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