Compare commits

..

7 Commits

Author SHA1 Message Date
DaniAkash
354b074780 chore(merge): sync feat/program-scheduler with dev 2026-04-20 22:24:39 +05:30
DaniAkash
678d9368d6 fix: update program scheduling copy 2026-04-15 15:06:41 +05:30
DaniAkash
865ef21b5b feat: add program run result dialog 2026-04-15 15:05:27 +05:30
DaniAkash
4cba9e2020 feat: add browseros program scheduler 2026-04-15 13:08:44 +05:30
DaniAkash
d343fd1735 fix: remove openclaw role bootstrap assertion 2026-04-15 12:20:44 +05:30
DaniAkash
63fed8e79b feat: add agent program management ui 2026-04-15 12:19:54 +05:30
Dani Akash
1fdad55b4a feat: add agent program management backend 2026-04-14 20:15:21 +05:30
69 changed files with 3788 additions and 3669 deletions

View File

@@ -1,158 +0,0 @@
name: build-agent
on:
workflow_dispatch:
inputs:
agent:
description: "Agent name from bundle.json"
required: true
type: string
default: openclaw
publish:
description: "Upload to R2 and merge manifest slice"
required: false
default: false
type: boolean
pull_request:
paths:
- "packages/browseros-agent/packages/build-tools/**"
- "!packages/browseros-agent/packages/build-tools/scripts/build-disk.ts"
- ".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"

View File

@@ -1,179 +0,0 @@
name: build-vm
on:
workflow_dispatch:
inputs:
version:
description: "VM version (e.g. 2026.04.22)"
required: true
type: string
publish:
description: "Upload to R2 and merge manifest slice"
required: false
default: false
type: boolean
pull_request:
paths:
- "packages/browseros-agent/packages/build-tools/**"
- "!packages/browseros-agent/packages/build-tools/scripts/build-tarball.ts"
- ".github/workflows/build-vm.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
if: ${{ github.event_name == 'workflow_dispatch' }}
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 libguestfs, qemu, and zstd
run: |
sudo apt-get update
sudo apt-get install -y libguestfs-tools qemu-utils zstd
sudo chmod 0644 /boot/vmlinuz-* /boot/initrd.img-* || true
[ -e /dev/kvm ] && sudo chmod 0666 /dev/kvm || true
[ -x /usr/bin/passt ] && sudo mv /usr/bin/passt /usr/bin/passt.disabled || true
echo "LIBGUESTFS_BACKEND=direct" >> "$GITHUB_ENV"
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- name: Validate VM version input
if: ${{ github.event_name == 'workflow_dispatch' }}
working-directory: ${{ env.PKG_DIR }}
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
bundle_version="$(bun -e "const b = await Bun.file('bundle.json').json(); console.log(b.vmVersion)")"
if [ "$VERSION" != "$bundle_version" ]; then
echo "inputs.version ($VERSION) must match bundle.json vmVersion ($bundle_version)" >&2
exit 1
fi
- name: Build disk
working-directory: ${{ env.PKG_DIR }}
env:
VERSION: ${{ inputs.version || format('pr-{0}', github.event.pull_request.number) }}
OUT: ${{ github.workspace }}/dist/vm
run: bun run build:disk -- --version "$VERSION" --arch "${{ matrix.arch }}" --output-dir "$OUT"
- uses: actions/upload-artifact@v4
with:
name: vm-disk-${{ matrix.arch }}
path: dist/vm/
retention-days: 7
smoke:
needs: build
if: ${{ github.event_name == 'workflow_dispatch' }}
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: vm-disk-arm64
path: dist/vm
- name: Install qemu, zstd, curl, and Lima
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y qemu-system-arm qemu-utils zstd curl
lima_version="$(cat "${PKG_DIR}/recipe/LIMA_VERSION.pin")"
lima_sha256="$(cat "${PKG_DIR}/recipe/LIMA_LINUX_AARCH64_SHA256.pin")"
curl -fsSL -o /tmp/lima.tar.gz \
"https://github.com/lima-vm/lima/releases/download/${lima_version}/lima-${lima_version#v}-Linux-aarch64.tar.gz"
echo "${lima_sha256} /tmp/lima.tar.gz" | sha256sum --check --strict
sudo tar -C /usr/local -xzf /tmp/lima.tar.gz
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- name: Smoke test VM disk
working-directory: ${{ env.PKG_DIR }}
run: |
set -euo pipefail
qcow="$(find "$GITHUB_WORKSPACE/dist/vm" -name 'browseros-vm-*-arm64.qcow2.zst' -print -quit)"
if [ -z "$qcow" ]; then
echo "missing arm64 VM disk artifact" >&2
exit 1
fi
bun run smoke:vm -- --arch arm64 --qcow "$qcow" --limactl /usr/local/bin/limactl
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: vm-disk-*
path: dist
merge-multiple: true
- working-directory: packages/browseros-agent
run: bun install --frozen-lockfile
- name: Upload VM disks 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/*.qcow2.zst; do
base="$(basename "$file")"
bun run upload -- --file "$file" --key "vm/$base" --content-type "application/zstd" --sidecar-sha
done
- name: Merge VM slice into manifest
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
mkdir -p dist
cp -R "$GITHUB_WORKSPACE"/dist/* dist/
bun run download -- --key vm/manifest.json --out dist/baseline-manifest.json
bun run emit-manifest -- \
--slice vm \
--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"

View File

@@ -14,7 +14,6 @@ lerna-debug.log*
# Ignore all .env files except .env.example
**/.env.*
!**/.env.example
!**/.env.sample
!**/.env.production.example

View File

@@ -218,9 +218,3 @@ This uses the same element resolution as the server's MCP tools — no coordinat
The `<target>` argument can be:
- An **index** from the `targets` output (e.g., `3`)
- A **URL substring** (e.g., `sidepanel`, `newtab`, `chrome-extension://`)
## Release gating — bundled-VM runtime migration (2026-Q2)
Between the Lima server-prod-resources cutover (WS3) and the ContainerRuntime migration (WS6) landing, `resources/bin/third_party/` ships `limactl` instead of `podman`. The current OpenClaw runtime (`apps/server/src/api/services/openclaw/podman-runtime.ts`, `container-runtime.ts`) still invokes `podman`; it will fail to find the binary on builds cut from `dev`.
Do **not** cut a release branch off `dev` during this window. Track WS6 progress before any release cut. See `specs/bundled-vm-runtime-spec.md` + `specs/workstreams.md` for context.

View File

@@ -1,3 +1,7 @@
import type {
BrowserOSCustomRoleInput,
BrowserOSRoleBoundary,
} from '@browseros/shared/types/role-aware-agents'
import {
AlertCircle,
ChevronDown,
@@ -33,26 +37,55 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { AgentChat } from './AgentChat'
import { AgentTerminal } from './AgentTerminal'
import { getOpenClawSupportedProviders } from './openclaw-supported-providers'
import { AgentProgramsPage } from './programs/AgentProgramsPage'
import {
type AgentEntry,
type GatewayLifecycleAction,
type OpenClawStatus,
type RoleTemplateSummary,
useOpenClawAgents,
useOpenClawMutations,
useOpenClawRoles,
useOpenClawStatus,
usePodmanOverrides,
} from './useOpenClaw'
const LIFECYCLE_BANNER_COPY: Record<GatewayLifecycleAction, string> = {
setup: 'Setting up OpenClaw...',
start: 'Starting gateway...',
stop: 'Stopping gateway...',
restart: 'Restarting gateway...',
reconnect: 'Restoring gateway connection...',
const CUSTOM_ROLE_VALUE = '__custom__'
const PLAIN_AGENT_VALUE = '__plain__'
type AgentCreationMode = 'builtin' | 'custom' | 'plain'
function createDefaultCustomRoleBoundaries(): BrowserOSRoleBoundary[] {
return [
{
key: 'draft-external-comms',
label: 'Draft external communications',
description: 'May prepare outbound messages for review.',
defaultMode: 'allow',
},
{
key: 'send-external-comms',
label: 'Send external communications',
description: 'Should require approval before sending messages.',
defaultMode: 'ask',
},
{
key: 'calendar-mutations',
label: 'Modify calendar events',
description: 'Should ask before moving or creating calendar events.',
defaultMode: 'ask',
},
]
}
function parseCommaSeparatedList(input: string): string[] {
return input
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
const CONTROL_PLANE_COPY: Record<
@@ -238,13 +271,17 @@ const ProviderSelector: FC<ProviderSelectorProps> = ({
)
}
const PodmanOverridesCard: FC = () => {
interface PodmanOverridesCardProps {
variant: 'inline' | 'standalone'
}
const PodmanOverridesCard: FC<PodmanOverridesCardProps> = ({ variant }) => {
const { overrides, loading, saving, error, saveOverrides, clearOverrides } =
usePodmanOverrides()
const [value, setValue] = useState('')
const [touched, setTouched] = useState(false)
const [collapsed, setCollapsed] = useState(true)
const [collapsed, setCollapsed] = useState(variant === 'standalone')
const [localError, setLocalError] = useState<string | null>(null)
useEffect(() => {
@@ -281,11 +318,14 @@ const PodmanOverridesCard: FC = () => {
const body = (
<div className="space-y-3">
<div className="space-y-1">
<label htmlFor="podman-path" className="font-medium text-sm">
<label
htmlFor={`podman-path-${variant}`}
className="font-medium text-sm"
>
Podman binary path
</label>
<Input
id="podman-path"
id={`podman-path-${variant}`}
value={value}
onChange={(event) => {
setTouched(true)
@@ -334,6 +374,15 @@ const PodmanOverridesCard: FC = () => {
</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
@@ -368,6 +417,7 @@ export const AgentsPage: FC = () => {
loading: agentsLoading,
error: agentsError,
} = useOpenClawAgents(agentsQueryEnabled)
const { roles, loading: rolesLoading, error: rolesError } = useOpenClawRoles()
const {
setupOpenClaw,
createAgent,
@@ -381,20 +431,47 @@ export const AgentsPage: FC = () => {
creating,
deleting,
reconnecting,
pendingGatewayAction,
} = useOpenClawMutations()
const [setupOpen, setSetupOpen] = useState(false)
const [setupProviderId, setSetupProviderId] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [selectedRoleValue, setSelectedRoleValue] = useState<
| RoleTemplateSummary['id']
| typeof CUSTOM_ROLE_VALUE
| typeof PLAIN_AGENT_VALUE
>('chief-of-staff')
const [newName, setNewName] = useState('')
const [createProviderId, setCreateProviderId] = useState('')
const [customRole, setCustomRole] = useState<BrowserOSCustomRoleInput>({
name: '',
shortDescription: '',
longDescription: '',
recommendedApps: [],
boundaries: createDefaultCustomRoleBoundaries(),
})
const [chatAgent, setChatAgent] = useState<AgentEntry | null>(null)
const [programAgent, setProgramAgent] = useState<AgentEntry | null>(null)
const [showTerminal, setShowTerminal] = useState(false)
const [error, setError] = useState<string | null>(null)
const compatibleProviders = getOpenClawSupportedProviders(providers)
const creationMode: AgentCreationMode =
selectedRoleValue === CUSTOM_ROLE_VALUE
? 'custom'
: selectedRoleValue === PLAIN_AGENT_VALUE
? 'plain'
: 'builtin'
const isCustomRole = creationMode === 'custom'
const isPlainAgent = creationMode === 'plain'
const selectedRole =
creationMode === 'builtin'
? (roles.find((role) => role.id === selectedRoleValue) ??
roles[0] ??
null)
: null
useEffect(() => {
if (compatibleProviders.length === 0) return
@@ -414,17 +491,47 @@ export const AgentsPage: FC = () => {
])
useEffect(() => {
if (!createOpen) return
setNewName((current) => current || 'agent')
}, [createOpen])
if (!createOpen || roles.length === 0) return
const lifecyclePending = pendingGatewayAction !== null
const inlineError = lifecyclePending
? null
: (error ?? statusError?.message ?? agentsError?.message ?? null)
const lifecycleBanner = pendingGatewayAction
? LIFECYCLE_BANNER_COPY[pendingGatewayAction]
: null
const defaultRole = roles.find((role) => role.id === 'chief-of-staff')
const nextRole = defaultRole ?? roles[0]
setSelectedRoleValue((current) => {
if (current === CUSTOM_ROLE_VALUE || current === PLAIN_AGENT_VALUE)
return current
const hasCurrent = roles.some((role) => role.id === current)
return hasCurrent ? current : nextRole.id
})
setNewName((current) => current || nextRole.defaultAgentName)
}, [createOpen, roles])
useEffect(() => {
if (!createOpen) return
if (isCustomRole) {
setNewName(
(current) =>
current || customRole.name.trim().toLowerCase().replace(/\s+/g, '-'),
)
return
}
if (isPlainAgent) {
setNewName((current) => current || 'agent')
return
}
if (selectedRole) {
setNewName((current) => current || selectedRole.defaultAgentName)
}
}, [createOpen, isCustomRole, isPlainAgent, customRole.name, selectedRole])
const inlineError =
error ??
statusError?.message ??
agentsError?.message ??
rolesError?.message ??
null
const gatewayUiState = useMemo(() => {
if (!status) {
@@ -453,10 +560,6 @@ 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)
@@ -494,10 +597,34 @@ export const AgentsPage: FC = () => {
(item) => item.id === createProviderId,
)
const normalizedName = newName.trim().toLowerCase().replace(/\s+/g, '-')
const customRolePayload = isCustomRole
? {
...customRole,
name: customRole.name.trim(),
shortDescription: customRole.shortDescription.trim(),
longDescription: customRole.longDescription.trim(),
}
: undefined
if (
isCustomRole &&
(!customRolePayload?.name ||
!customRolePayload.shortDescription ||
!customRolePayload.longDescription)
) {
setError(
'Custom roles require a role name, short description, and long description.',
)
return
}
if (creationMode === 'builtin' && !selectedRole) return
await runWithErrorHandling(async () => {
await createAgent({
name: normalizedName,
roleId: creationMode === 'builtin' ? selectedRole?.id : undefined,
customRole: isCustomRole ? customRolePayload : undefined,
providerType: provider?.type,
providerName: provider?.name,
baseUrl: provider?.baseUrl,
@@ -506,6 +633,13 @@ export const AgentsPage: FC = () => {
})
setCreateOpen(false)
setNewName('')
setCustomRole({
name: '',
shortDescription: '',
longDescription: '',
recommendedApps: [],
boundaries: createDefaultCustomRoleBoundaries(),
})
})
}
@@ -553,6 +687,15 @@ export const AgentsPage: FC = () => {
)
}
if (programAgent) {
return (
<AgentProgramsPage
agent={programAgent}
onBack={() => setProgramAgent(null)}
/>
)
}
if (statusLoading && !status) {
return (
<div className="flex items-center justify-center py-20">
@@ -620,7 +763,7 @@ export const AgentsPage: FC = () => {
</Button>
<Button
onClick={() => setCreateOpen(true)}
disabled={!canManageAgents}
disabled={!gatewayUiState.canManageAgents}
>
<Plus className="mr-1 size-4" />
New Agent
@@ -631,13 +774,6 @@ export const AgentsPage: FC = () => {
)}
</div>
{lifecycleBanner && (
<Alert>
<Loader2 className="animate-spin" />
<AlertTitle>{lifecycleBanner}</AlertTitle>
</Alert>
)}
{inlineError && (
<Alert variant="destructive">
<AlertCircle />
@@ -657,7 +793,7 @@ export const AgentsPage: FC = () => {
</Alert>
)}
{status && showControlPlaneDegraded && (
{status && gatewayUiState.controlPlaneDegraded && (
<Alert
variant={
status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'
@@ -697,6 +833,7 @@ export const AgentsPage: FC = () => {
Restart Gateway
</Button>
</div>
<PodmanOverridesCard variant="inline" />
</AlertDescription>
</Alert>
)}
@@ -716,6 +853,9 @@ 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>
)}
@@ -759,6 +899,9 @@ export const AgentsPage: FC = () => {
Restart Gateway
</Button>
</div>
<div className="w-full max-w-md">
<PodmanOverridesCard variant="inline" />
</div>
</CardContent>
</Card>
)}
@@ -778,7 +921,7 @@ export const AgentsPage: FC = () => {
<Button
variant="outline"
onClick={() => setCreateOpen(true)}
disabled={!canManageAgents}
disabled={!gatewayUiState.canManageAgents}
>
<Plus className="mr-1 size-4" />
Create Agent
@@ -796,18 +939,37 @@ export const AgentsPage: FC = () => {
<CardTitle className="text-base">
{agent.name}
</CardTitle>
{agent.role && (
<Badge variant="secondary">
{agent.role.roleName}
</Badge>
)}
</div>
<p className="font-mono text-muted-foreground text-xs">
{agent.workspace}
</p>
{agent.role && (
<p className="text-muted-foreground text-xs">
{agent.role.shortDescription}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setProgramAgent(agent)}
disabled={!gatewayUiState.canManageAgents}
>
<Wrench className="mr-1 size-4" />
Programs
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setChatAgent(agent)}
disabled={!canManageAgents}
disabled={!gatewayUiState.canManageAgents}
>
<MessageSquare className="mr-1 size-4" />
Chat
@@ -817,7 +979,7 @@ export const AgentsPage: FC = () => {
variant="ghost"
size="icon"
onClick={() => handleDelete(agent.agentId)}
disabled={!canManageAgents || deleting}
disabled={!gatewayUiState.canManageAgents || deleting}
>
<Trash2 className="size-4 text-destructive" />
</Button>
@@ -830,7 +992,7 @@ export const AgentsPage: FC = () => {
</div>
)}
<PodmanOverridesCard />
<PodmanOverridesCard variant="standalone" />
<Dialog open={setupOpen} onOpenChange={setSetupOpen}>
<DialogContent>
@@ -868,6 +1030,246 @@ export const AgentsPage: FC = () => {
<DialogTitle>Create Agent</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="agent-role">
Agent Role
</label>
<Select
value={selectedRoleValue}
onValueChange={(value) => {
if (value === CUSTOM_ROLE_VALUE) {
setSelectedRoleValue(CUSTOM_ROLE_VALUE)
setNewName(
customRole.name
.trim()
.toLowerCase()
.replace(/\s+/g, '-') || 'custom-agent',
)
return
}
if (value === PLAIN_AGENT_VALUE) {
setSelectedRoleValue(PLAIN_AGENT_VALUE)
setNewName('agent')
return
}
const role = roles.find((item) => item.id === value)
if (!role) return
setSelectedRoleValue(role.id)
setNewName(role.defaultAgentName)
}}
disabled={rolesLoading}
>
<SelectTrigger id="agent-role">
<SelectValue
placeholder={
rolesLoading ? 'Loading roles...' : 'Select a role'
}
/>
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))}
<SelectItem value={PLAIN_AGENT_VALUE}>Plain Agent</SelectItem>
<SelectItem value={CUSTOM_ROLE_VALUE}>Custom Role</SelectItem>
</SelectContent>
</Select>
{selectedRole && !isCustomRole && (
<Card>
<CardContent className="space-y-3 py-4">
<div>
<div className="font-medium text-sm">
{selectedRole.name}
</div>
<p className="text-muted-foreground text-xs">
{selectedRole.shortDescription}
</p>
</div>
<div>
<div className="font-medium text-xs">
Recommended Apps
</div>
<p className="text-muted-foreground text-xs">
{selectedRole.recommendedApps.join(', ')}
</p>
</div>
<div>
<div className="font-medium text-xs">
Default Boundaries
</div>
<ul className="space-y-1 text-muted-foreground text-xs">
{selectedRole.boundaries.map((boundary) => (
<li key={boundary.key}>
{boundary.label}: {boundary.defaultMode}
</li>
))}
</ul>
</div>
</CardContent>
</Card>
)}
{isPlainAgent && (
<Card>
<CardContent className="space-y-2 py-4">
<div className="font-medium text-sm">Plain Agent</div>
<p className="text-muted-foreground text-xs">
No role bootstrap or defaults. Intended for temporary
development and testing only.
</p>
</CardContent>
</Card>
)}
</div>
{isCustomRole && (
<Card>
<CardContent className="space-y-4 py-4">
<div className="space-y-2">
<label
htmlFor="custom-role-name"
className="font-medium text-sm"
>
Custom Role Name
</label>
<Input
id="custom-role-name"
value={customRole.name}
onChange={(event) => {
const name = event.target.value
setCustomRole((current) => ({ ...current, name }))
setNewName(
name.trim().toLowerCase().replace(/\s+/g, '-') ||
'custom-agent',
)
}}
placeholder="Board Prep Operator"
/>
</div>
<div className="space-y-2">
<label
htmlFor="custom-role-short-description"
className="font-medium text-sm"
>
Short Description
</label>
<Input
id="custom-role-short-description"
value={customRole.shortDescription}
onChange={(event) =>
setCustomRole((current) => ({
...current,
shortDescription: event.target.value,
}))
}
placeholder="Prepares executive briefs and weekly follow-ups."
/>
</div>
<div className="space-y-2">
<label
htmlFor="custom-role-long-description"
className="font-medium text-sm"
>
Long Description
</label>
<Textarea
id="custom-role-long-description"
value={customRole.longDescription}
onChange={(event) =>
setCustomRole((current) => ({
...current,
longDescription: event.target.value,
}))
}
placeholder="Describe the role, purpose, and what kinds of outcomes this agent should produce."
rows={4}
/>
</div>
<div className="space-y-2">
<label
htmlFor="custom-role-apps"
className="font-medium text-sm"
>
Recommended Apps
</label>
<Input
id="custom-role-apps"
value={customRole.recommendedApps.join(', ')}
onChange={(event) =>
setCustomRole((current) => ({
...current,
recommendedApps: parseCommaSeparatedList(
event.target.value,
),
}))
}
placeholder="gmail, slack, notion"
/>
<p className="text-muted-foreground text-xs">
Comma-separated. Used as role guidance only in this
milestone.
</p>
</div>
<div className="space-y-3">
<div>
<div className="font-medium text-sm">
Boundary Defaults
</div>
<p className="text-muted-foreground text-xs">
Set the starting behavior for common high-impact
actions.
</p>
</div>
{customRole.boundaries.map((boundary) => (
<div
key={boundary.key}
className="grid gap-2 rounded-lg border p-3"
>
<div>
<div className="font-medium text-sm">
{boundary.label}
</div>
<p className="text-muted-foreground text-xs">
{boundary.description}
</p>
</div>
<Select
value={boundary.defaultMode}
onValueChange={(value) =>
setCustomRole((current) => ({
...current,
boundaries: current.boundaries.map((item) =>
item.key === boundary.key
? {
...item,
defaultMode:
value as BrowserOSRoleBoundary['defaultMode'],
}
: item,
),
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="allow">Allow</SelectItem>
<SelectItem value="ask">Ask</SelectItem>
<SelectItem value="block">Block</SelectItem>
</SelectContent>
</Select>
</div>
))}
</div>
</CardContent>
</Card>
)}
<div>
<label
htmlFor="agent-name"
@@ -901,8 +1303,10 @@ export const AgentsPage: FC = () => {
disabled={
!newName.trim() ||
creating ||
!canManageAgents ||
compatibleProviders.length === 0
rolesLoading ||
!gatewayUiState.canManageAgents ||
compatibleProviders.length === 0 ||
(creationMode === 'builtin' && !selectedRole)
}
className="w-full"
>

View File

@@ -0,0 +1,359 @@
import type {
BrowserOSAgentProgram,
BrowserOSProgramRun,
CreateAgentProgramInput,
UpdateAgentProgramInput,
} from '@browseros/shared/types/role-programs'
import {
ArrowLeft,
CalendarClock,
Loader2,
Play,
Plus,
Trash2,
} from 'lucide-react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import type { AgentEntry } from '../useOpenClaw'
import {
useOpenClawMutations,
useOpenClawProgramRuns,
useOpenClawPrograms,
} from '../useOpenClaw'
import { ProgramFormDialog } from './ProgramFormDialog'
import { ProgramRunHistory } from './ProgramRunHistory'
import { ProgramRunResultDialog } from './ProgramRunResultDialog'
interface AgentProgramsPageProps {
agent: AgentEntry
onBack: () => void
}
function describeSchedule(program: BrowserOSAgentProgram): string {
switch (program.schedule.type) {
case 'manual':
return 'Manual only'
case 'daily':
return `Daily at ${program.schedule.time}`
case 'hourly':
return `Every ${program.schedule.interval} hour(s)`
case 'minutes':
return `Every ${program.schedule.interval} minute(s)`
}
}
export function AgentProgramsPage({ agent, onBack }: AgentProgramsPageProps) {
const {
programs,
loading: programsLoading,
error: programsError,
} = useOpenClawPrograms(agent.agentId)
const {
runs,
loading: runsLoading,
error: runsError,
} = useOpenClawProgramRuns(agent.agentId)
const {
createProgram,
updateProgram,
deleteProgram,
runProgram,
creatingProgram,
updatingProgram,
deletingProgram,
runningProgram,
} = useOpenClawMutations()
const [dialogOpen, setDialogOpen] = useState(false)
const [editingProgram, setEditingProgram] =
useState<BrowserOSAgentProgram | null>(null)
const [viewingRunId, setViewingRunId] = useState<string | null>(null)
const programNames = useMemo(
() =>
Object.fromEntries(programs.map((program) => [program.id, program.name])),
[programs],
)
const viewingRun: BrowserOSProgramRun | null = viewingRunId
? (runs.find((run) => run.id === viewingRunId) ?? null)
: null
const saving = creatingProgram || updatingProgram
const handleCreate = async (
input: CreateAgentProgramInput | UpdateAgentProgramInput,
) => {
try {
if (editingProgram) {
await updateProgram({
agentId: agent.agentId,
programId: editingProgram.id,
input: input as UpdateAgentProgramInput,
})
toast.success('Program updated')
} else {
await createProgram({
agentId: agent.agentId,
input: input as CreateAgentProgramInput,
})
toast.success('Program created')
}
setDialogOpen(false)
setEditingProgram(null)
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to save program',
)
}
}
const handleToggle = async (
program: BrowserOSAgentProgram,
enabled: boolean,
) => {
try {
await updateProgram({
agentId: agent.agentId,
programId: program.id,
input: { enabled },
})
toast.success(enabled ? 'Program enabled' : 'Program disabled')
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to update program',
)
}
}
const handleDelete = async (program: BrowserOSAgentProgram) => {
try {
await deleteProgram({
agentId: agent.agentId,
programId: program.id,
})
toast.success(`Deleted "${program.name}"`)
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to delete program',
)
}
}
const handleRunNow = async (program: BrowserOSAgentProgram) => {
try {
const result = await runProgram({
agentId: agent.agentId,
programId: program.id,
})
if (result.run.status === 'failed') {
toast.error(
result.run.error ?? `Program run failed for "${program.name}"`,
)
return
}
toast.success(`Completed "${program.name}"`)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Program run failed')
}
}
const inlineError = programsError?.message ?? runsError?.message ?? null
return (
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="size-4" />
</Button>
<div>
<h1 className="font-bold text-2xl">{agent.name} Programs</h1>
<p className="text-muted-foreground text-sm">
Define and manually test reusable responsibilities for this agent.
</p>
</div>
</div>
{inlineError && (
<Card className="border-destructive">
<CardContent className="py-4 text-destructive text-sm">
{inlineError}
</CardContent>
</Card>
)}
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle className="text-base">Programs</CardTitle>
<p className="text-muted-foreground text-sm">
Define reusable responsibilities for this agent. Programs can run
on schedule or be triggered manually for validation.
</p>
</div>
<Button
onClick={() => {
setEditingProgram(null)
setDialogOpen(true)
}}
>
<Plus className="mr-2 size-4" />
New Program
</Button>
</CardHeader>
<CardContent>
{programsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : programs.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-muted-foreground text-sm">
No programs yet. Create your first program to define a recurring
responsibility for this agent.
</div>
) : (
<div className="space-y-4">
{programs.map((program) => (
<div key={program.id} className="rounded-lg border p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{program.name}</div>
<Badge
variant={program.enabled ? 'default' : 'outline'}
>
{program.enabled ? 'Enabled' : 'Disabled'}
</Badge>
<Badge variant="secondary">
{describeSchedule(program)}
</Badge>
</div>
<p className="text-muted-foreground text-sm">
{program.description}
</p>
<p className="line-clamp-4 text-sm">{program.prompt}</p>
<div className="text-muted-foreground text-xs">
Last run:{' '}
{program.lastRunAt
? new Date(program.lastRunAt).toLocaleString()
: 'Never'}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 rounded-lg border px-3 py-2">
<span className="text-sm">Enabled</span>
<Switch
checked={program.enabled}
onCheckedChange={(checked) =>
void handleToggle(program, checked)
}
disabled={updatingProgram}
/>
</div>
<Button
variant="outline"
onClick={() => void handleRunNow(program)}
disabled={runningProgram}
>
<Play className="mr-2 size-4" />
Run Now
</Button>
<Button
variant="outline"
onClick={() => {
setEditingProgram(program)
setDialogOpen(true)
}}
>
<CalendarClock className="mr-2 size-4" />
Edit
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => void handleDelete(program)}
disabled={deletingProgram}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</div>
{program.standingOrders.length > 0 && (
<>
<Separator className="my-4" />
<div className="space-y-2">
<div className="font-medium text-sm">
Standing Orders
</div>
<div className="space-y-2">
{program.standingOrders.map((order) => (
<div
key={order.id}
className="rounded-md bg-muted/40 px-3 py-2 text-sm"
>
<div className="flex items-center gap-2">
<span className="font-medium">
{order.title}
</span>
<Badge
variant={
order.enabled ? 'secondary' : 'outline'
}
>
{order.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<p className="mt-1 text-muted-foreground text-xs">
{order.instruction}
</p>
</div>
))}
</div>
</div>
</>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
<ProgramRunHistory
runs={runs}
loading={runsLoading}
programNames={programNames}
onViewRun={(run) => setViewingRunId(run.id)}
/>
<ProgramFormDialog
open={dialogOpen}
program={editingProgram}
saving={saving}
onOpenChange={(open) => {
setDialogOpen(open)
if (!open) {
setEditingProgram(null)
}
}}
onSave={handleCreate}
/>
<ProgramRunResultDialog
run={viewingRun}
programName={
viewingRun
? (programNames[viewingRun.programId] ?? 'Unknown Program')
: undefined
}
onOpenChange={(open) => !open && setViewingRunId(null)}
/>
</div>
)
}

View File

@@ -0,0 +1,448 @@
import type {
BrowserOSAgentProgram,
BrowserOSProgramSchedule,
BrowserOSStandingOrder,
CreateAgentProgramInput,
UpdateAgentProgramInput,
} from '@browseros/shared/types/role-programs'
import { Plus, Trash2 } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
type ProgramScheduleType = BrowserOSProgramSchedule['type']
interface ProgramDraft {
name: string
description: string
prompt: string
enabled: boolean
scheduleType: ProgramScheduleType
scheduleTime: string
scheduleInterval: number
standingOrders: BrowserOSStandingOrder[]
}
interface ProgramFormDialogProps {
open: boolean
program: BrowserOSAgentProgram | null
saving: boolean
onOpenChange: (open: boolean) => void
onSave: (
input: CreateAgentProgramInput | UpdateAgentProgramInput,
) => Promise<void>
}
function createEmptyStandingOrder(): BrowserOSStandingOrder {
return {
id: crypto.randomUUID(),
title: '',
instruction: '',
enabled: true,
}
}
function toDraft(program: BrowserOSAgentProgram | null): ProgramDraft {
if (!program) {
return {
name: '',
description: '',
prompt: '',
enabled: true,
scheduleType: 'manual',
scheduleTime: '09:00',
scheduleInterval: 1,
standingOrders: [],
}
}
return {
name: program.name,
description: program.description,
prompt: program.prompt,
enabled: program.enabled,
scheduleType: program.schedule.type,
scheduleTime:
program.schedule.type === 'daily' ? program.schedule.time : '09:00',
scheduleInterval:
program.schedule.type === 'hourly' || program.schedule.type === 'minutes'
? program.schedule.interval
: 1,
standingOrders: program.standingOrders,
}
}
function toSchedule(draft: ProgramDraft): BrowserOSProgramSchedule {
switch (draft.scheduleType) {
case 'daily':
return {
type: 'daily',
time: draft.scheduleTime,
}
case 'hourly':
return {
type: 'hourly',
interval: draft.scheduleInterval,
}
case 'minutes':
return {
type: 'minutes',
interval: draft.scheduleInterval,
}
case 'manual':
default:
return { type: 'manual' }
}
}
export function ProgramFormDialog({
open,
program,
saving,
onOpenChange,
onSave,
}: ProgramFormDialogProps) {
const [draft, setDraft] = useState<ProgramDraft>(() => toDraft(program))
useEffect(() => {
if (!open) return
setDraft(toDraft(program))
}, [open, program])
const isEditing = !!program
const canSave = useMemo(() => {
return (
draft.name.trim() !== '' &&
draft.description.trim() !== '' &&
draft.prompt.trim() !== ''
)
}, [draft])
const handleSave = async () => {
const payload = {
name: draft.name.trim(),
description: draft.description.trim(),
prompt: draft.prompt.trim(),
schedule: toSchedule(draft),
enabled: draft.enabled,
standingOrders: draft.standingOrders
.filter(
(order) =>
order.title.trim() !== '' || order.instruction.trim() !== '',
)
.map((order) => ({
...order,
title: order.title.trim(),
instruction: order.instruction.trim(),
})),
}
await onSave(payload)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{isEditing ? 'Edit Program' : 'Create Program'}
</DialogTitle>
<DialogDescription>
Define a reusable responsibility for this agent. Automatic schedule
execution lands in the next milestone, but you can save and run it
manually now.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="program-name">
Program Name
</label>
<Input
id="program-name"
value={draft.name}
onChange={(event) =>
setDraft((current) => ({
...current,
name: event.target.value,
}))
}
placeholder="Morning Brief"
/>
</div>
<div className="space-y-2">
<label
className="font-medium text-sm"
htmlFor="program-description"
>
Description
</label>
<Input
id="program-description"
value={draft.description}
onChange={(event) =>
setDraft((current) => ({
...current,
description: event.target.value,
}))
}
placeholder="Prepare the executive morning brief."
/>
</div>
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="program-prompt">
Prompt
</label>
<Textarea
id="program-prompt"
rows={6}
value={draft.prompt}
onChange={(event) =>
setDraft((current) => ({
...current,
prompt: event.target.value,
}))
}
placeholder="Review email, Slack, calendar, Linear, and Notion for urgent updates..."
/>
</div>
<div className="grid gap-4 md:grid-cols-[1fr_auto]">
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="program-schedule">
Schedule
</label>
<Select
value={draft.scheduleType}
onValueChange={(value) =>
setDraft((current) => ({
...current,
scheduleType: value as ProgramScheduleType,
}))
}
>
<SelectTrigger id="program-schedule">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual only</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="minutes">Every N minutes</SelectItem>
</SelectContent>
</Select>
</div>
{draft.scheduleType === 'daily' && (
<div className="space-y-2">
<label className="font-medium text-sm" htmlFor="program-time">
Time
</label>
<Input
id="program-time"
type="time"
value={draft.scheduleTime}
onChange={(event) =>
setDraft((current) => ({
...current,
scheduleTime: event.target.value,
}))
}
/>
</div>
)}
{(draft.scheduleType === 'hourly' ||
draft.scheduleType === 'minutes') && (
<div className="space-y-2">
<label
className="font-medium text-sm"
htmlFor="program-interval"
>
Interval
</label>
<Input
id="program-interval"
type="number"
min={1}
value={draft.scheduleInterval}
onChange={(event) =>
setDraft((current) => ({
...current,
scheduleInterval: Math.max(
1,
Number(event.target.value) || 1,
),
}))
}
/>
</div>
)}
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<div className="font-medium text-sm">Enabled</div>
<p className="text-muted-foreground text-xs">
Save this program as active for future scheduling.
</p>
</div>
<Switch
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft((current) => ({ ...current, enabled: checked }))
}
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-sm">Standing Orders</div>
<p className="text-muted-foreground text-xs">
Persistent instructions that should always guide this program.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
setDraft((current) => ({
...current,
standingOrders: [
...current.standingOrders,
createEmptyStandingOrder(),
],
}))
}
>
<Plus className="mr-2 size-4" />
Add Order
</Button>
</div>
{draft.standingOrders.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-muted-foreground text-sm">
No standing orders yet.
</div>
) : (
<div className="space-y-3">
{draft.standingOrders.map((order) => (
<div
key={order.id}
className="space-y-3 rounded-lg border p-3"
>
<div className="flex items-center justify-between gap-2">
<Input
value={order.title}
onChange={(event) =>
setDraft((current) => ({
...current,
standingOrders: current.standingOrders.map(
(item) =>
item.id === order.id
? { ...item, title: event.target.value }
: item,
),
}))
}
placeholder="Keep it concise"
/>
<Button
variant="ghost"
size="icon"
onClick={() =>
setDraft((current) => ({
...current,
standingOrders: current.standingOrders.filter(
(item) => item.id !== order.id,
),
}))
}
>
<Trash2 className="size-4" />
</Button>
</div>
<Textarea
rows={3}
value={order.instruction}
onChange={(event) =>
setDraft((current) => ({
...current,
standingOrders: current.standingOrders.map((item) =>
item.id === order.id
? { ...item, instruction: event.target.value }
: item,
),
}))
}
placeholder="Keep the output concise and action-oriented."
/>
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2">
<span className="text-sm">Enabled</span>
<Switch
checked={order.enabled}
onCheckedChange={(checked) =>
setDraft((current) => ({
...current,
standingOrders: current.standingOrders.map(
(item) =>
item.id === order.id
? { ...item, enabled: checked }
: item,
),
}))
}
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancel
</Button>
<Button
onClick={() => void handleSave()}
disabled={!canSave || saving}
>
{saving
? 'Saving...'
: isEditing
? 'Save Changes'
: 'Create Program'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,121 @@
import type { BrowserOSProgramRun } from '@browseros/shared/types/role-programs'
import { AlertCircle, CheckCircle2, Clock3, Loader2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
interface ProgramRunHistoryProps {
runs: BrowserOSProgramRun[]
loading: boolean
programNames: Record<string, string>
onViewRun: (run: BrowserOSProgramRun) => void
}
function formatDateTime(value?: string): string {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
function RunStatusBadge({ status }: { status: BrowserOSProgramRun['status'] }) {
switch (status) {
case 'running':
return <Badge variant="secondary">Running</Badge>
case 'completed':
return <Badge variant="default">Completed</Badge>
case 'failed':
return <Badge variant="destructive">Failed</Badge>
case 'cancelled':
return <Badge variant="outline">Cancelled</Badge>
case 'pending':
default:
return <Badge variant="outline">Pending</Badge>
}
}
export function ProgramRunHistory({
runs,
loading,
programNames,
onViewRun,
}: ProgramRunHistoryProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Recent Runs</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : runs.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-muted-foreground text-sm">
No runs yet. Run a program manually to validate it.
</div>
) : (
<ScrollArea className="h-[340px] pr-3">
<div className="space-y-3">
{runs.map((run) => (
<div key={run.id} className="rounded-lg border p-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="font-medium text-sm">
{programNames[run.programId] ?? 'Unknown Program'}
</div>
<div className="text-muted-foreground text-xs">
Trigger: {run.trigger}
</div>
</div>
<RunStatusBadge status={run.status} />
</div>
<div className="mt-3 space-y-1 text-muted-foreground text-xs">
<div className="flex items-center gap-2">
<Clock3 className="size-3.5" />
Started: {formatDateTime(run.startedAt)}
</div>
<div className="flex items-center gap-2">
{run.status === 'failed' ? (
<AlertCircle className="size-3.5 text-destructive" />
) : (
<CheckCircle2 className="size-3.5 text-muted-foreground" />
)}
Completed: {formatDateTime(run.completedAt)}
</div>
</div>
{run.summary && <p className="mt-3 text-sm">{run.summary}</p>}
{!run.summary && run.finalResult && (
<p className="mt-3 line-clamp-4 text-sm">
{run.finalResult}
</p>
)}
{run.error && (
<p className="mt-3 text-destructive text-sm">{run.error}</p>
)}
{(run.finalResult || run.error) && (
<div className="mt-3 flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => onViewRun(run)}
>
View Results
</Button>
</div>
)}
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,124 @@
import type { BrowserOSProgramRun } from '@browseros/shared/types/role-programs'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import {
AlertCircle,
Check,
CheckCircle2,
Copy,
Loader2,
XCircle,
} from 'lucide-react'
import { type FC, useState } from 'react'
import { MessageResponse } from '@/components/ai-elements/message'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
dayjs.extend(duration)
interface ProgramRunResultDialogProps {
run: BrowserOSProgramRun | null
programName?: string
onOpenChange: (open: boolean) => void
}
const formatDateTime = (dateStr: string) =>
dayjs(dateStr).format('MMM D, YYYY, h:mm A')
function formatDuration(startedAt: string, completedAt?: string): string {
if (!completedAt) return 'Still running'
const diff = dayjs(completedAt).diff(dayjs(startedAt))
const d = dayjs.duration(diff)
const mins = Math.floor(d.asMinutes())
const secs = d.seconds()
if (mins === 0) return `${secs} seconds`
return `${mins}m ${secs}s`
}
export const ProgramRunResultDialog: FC<ProgramRunResultDialogProps> = ({
run,
programName,
onOpenChange,
}) => {
const [copied, setCopied] = useState(false)
const content = run?.finalResult ?? run?.error ?? ''
const handleCopy = async () => {
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (!run) return null
return (
<Dialog open={!!run} onOpenChange={onOpenChange}>
<DialogContent className="sm:w-[70vw] sm:max-w-4xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{run.status === 'completed' ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : run.status === 'failed' ? (
<XCircle className="h-5 w-5 text-destructive" />
) : (
<Loader2 className="h-5 w-5 animate-spin text-accent-orange" />
)}
{programName || 'Program Run'}
</DialogTitle>
<div className="text-muted-foreground text-sm">
{formatDateTime(run.startedAt)} {' '}
{formatDuration(run.startedAt, run.completedAt)}
</div>
</DialogHeader>
<ScrollArea className="max-h-[70vh]">
{run.status === 'failed' && run.error ? (
<div className="flex flex-col gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div className="flex items-center gap-2 text-muted-foreground">
<AlertCircle className="h-5 w-5" />
<span className="font-medium text-sm">Program failed</span>
</div>
<p className="text-destructive text-sm">{run.error}</p>
</div>
) : run.finalResult ? (
<div className="prose prose-sm dark:prose-invert [&_[data-streamdown='code-block']]:!w-full [&_[data-streamdown='table-wrapper']]:!w-full max-w-none break-words rounded-lg border border-border bg-muted/50 p-4 [&_[data-streamdown='table-wrapper']]:overflow-x-auto">
<MessageResponse>{run.finalResult}</MessageResponse>
</div>
) : (
<div className="rounded-lg border border-border bg-muted/50 p-4 text-muted-foreground text-sm">
No result available
</div>
)}
</ScrollArea>
<DialogFooter>
{content && (
<Button variant="outline" onClick={handleCopy}>
{copied ? (
<>
<Check className="h-4 w-4" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
</>
)}
</Button>
)}
<Button onClick={() => onOpenChange(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,3 +1,13 @@
import type {
BrowserOSAgentRoleId,
BrowserOSCustomRoleInput,
} from '@browseros/shared/types/role-aware-agents'
import type {
BrowserOSAgentProgram,
BrowserOSProgramRun,
CreateAgentProgramInput,
UpdateAgentProgramInput,
} from '@browseros/shared/types/role-programs'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
@@ -7,6 +17,27 @@ export interface AgentEntry {
name: string
workspace: string
model?: unknown
role?: {
roleSource: 'builtin' | 'custom'
roleId?: BrowserOSAgentRoleId
roleName: string
shortDescription: string
}
}
export interface RoleTemplateSummary {
id: BrowserOSAgentRoleId
name: string
shortDescription: string
longDescription: string
recommendedApps: string[]
defaultAgentName: string
boundaries: Array<{
key: string
label: string
description: string
defaultMode: 'allow' | 'ask' | 'block'
}>
}
export interface OpenClawStatus {
@@ -16,6 +47,10 @@ export interface OpenClawStatus {
port: number | null
agentCount: number
error: string | null
scheduler?: {
running: boolean
activeProgramCount: number
}
controlPlaneStatus:
| 'disconnected'
| 'connecting'
@@ -36,6 +71,8 @@ export interface OpenClawStatus {
export interface OpenClawAgentMutationInput {
name: string
roleId?: BrowserOSAgentRoleId
customRole?: BrowserOSCustomRoleInput
providerType?: string
providerName?: string
baseUrl?: string
@@ -51,6 +88,9 @@ export interface OpenClawSetupInput {
modelId?: string
}
export interface AgentProgramEntry extends BrowserOSAgentProgram {}
export interface AgentProgramRunEntry extends BrowserOSProgramRun {}
export function getModelDisplayName(model: unknown): string | undefined {
if (typeof model === 'string') return model.split('/').pop()
return undefined
@@ -59,6 +99,9 @@ export function getModelDisplayName(model: unknown): string | undefined {
export const OPENCLAW_QUERY_KEYS = {
status: 'openclaw-status',
agents: 'openclaw-agents',
roles: 'openclaw-roles',
programs: 'openclaw-programs',
programRuns: 'openclaw-program-runs',
podmanOverrides: 'openclaw-podman-overrides',
} as const
@@ -67,13 +110,6 @@ export interface PodmanOverrides {
effectivePodmanPath: string
}
export type GatewayLifecycleAction =
| 'setup'
| 'start'
| 'stop'
| 'restart'
| 'reconnect'
async function clawFetch<T>(
baseUrl: string,
path: string,
@@ -102,6 +138,38 @@ async function fetchOpenClawAgents(baseUrl: string): Promise<AgentEntry[]> {
return data.agents ?? []
}
async function fetchOpenClawRoles(
baseUrl: string,
): Promise<RoleTemplateSummary[]> {
const data = await clawFetch<{ roles: RoleTemplateSummary[] }>(
baseUrl,
'/roles',
)
return data.roles ?? []
}
async function fetchOpenClawPrograms(
baseUrl: string,
agentId: string,
): Promise<AgentProgramEntry[]> {
const data = await clawFetch<{ programs: AgentProgramEntry[] }>(
baseUrl,
`/agents/${agentId}/programs`,
)
return data.programs ?? []
}
async function fetchOpenClawProgramRuns(
baseUrl: string,
agentId: string,
): Promise<AgentProgramRunEntry[]> {
const data = await clawFetch<{ runs: AgentProgramRunEntry[] }>(
baseUrl,
`/agents/${agentId}/program-runs`,
)
return data.runs ?? []
}
async function invalidateOpenClawQueries(
queryClient: ReturnType<typeof useQueryClient>,
): Promise<void> {
@@ -111,6 +179,27 @@ async function invalidateOpenClawQueries(
])
}
async function invalidateAgentProgramQueries(
queryClient: ReturnType<typeof useQueryClient>,
baseUrl: string,
agentId: string,
): Promise<void> {
await Promise.all([
queryClient.invalidateQueries({
queryKey: [OPENCLAW_QUERY_KEYS.programs, baseUrl, agentId],
}),
queryClient.invalidateQueries({
queryKey: [OPENCLAW_QUERY_KEYS.programRuns, baseUrl, agentId],
}),
queryClient.invalidateQueries({
queryKey: [OPENCLAW_QUERY_KEYS.agents, baseUrl],
}),
queryClient.invalidateQueries({
queryKey: [OPENCLAW_QUERY_KEYS.status, baseUrl],
}),
])
}
export function useOpenClawStatus(pollMs = 5000) {
const {
baseUrl,
@@ -154,6 +243,71 @@ export function useOpenClawAgents(enabled = true) {
}
}
export function useOpenClawRoles() {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<RoleTemplateSummary[], Error>({
queryKey: [OPENCLAW_QUERY_KEYS.roles, baseUrl],
queryFn: () => fetchOpenClawRoles(baseUrl as string),
enabled: !!baseUrl && !urlLoading,
staleTime: 60_000,
})
return {
roles: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useOpenClawPrograms(agentId: string | null, enabled = true) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<AgentProgramEntry[], Error>({
queryKey: [OPENCLAW_QUERY_KEYS.programs, baseUrl, agentId],
queryFn: () => fetchOpenClawPrograms(baseUrl as string, agentId as string),
enabled: !!baseUrl && !urlLoading && !!agentId && enabled,
})
return {
programs: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useOpenClawProgramRuns(agentId: string | null, enabled = true) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<AgentProgramRunEntry[], Error>({
queryKey: [OPENCLAW_QUERY_KEYS.programRuns, baseUrl, agentId],
queryFn: () =>
fetchOpenClawProgramRuns(baseUrl as string, agentId as string),
enabled: !!baseUrl && !urlLoading && !!agentId && enabled,
})
return {
runs: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useOpenClawMutations() {
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
const queryClient = useQueryClient()
@@ -166,6 +320,8 @@ export function useOpenClawMutations() {
}
const onSuccess = () => invalidateOpenClawQueries(queryClient)
const invalidateProgramsFor = (agentId: string) =>
invalidateAgentProgramQueries(queryClient, ensureBaseUrl(), agentId)
const setupMutation = useMutation({
mutationFn: async (input: OpenClawSetupInput) =>
@@ -231,12 +387,87 @@ 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'
const createProgramMutation = useMutation({
mutationFn: async ({
agentId,
input,
}: {
agentId: string
input: CreateAgentProgramInput
}) =>
clawFetch<{ program: AgentProgramEntry }>(
ensureBaseUrl(),
`/agents/${agentId}/programs`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
},
),
onSuccess: async (_data, variables) =>
invalidateProgramsFor(variables.agentId),
})
const updateProgramMutation = useMutation({
mutationFn: async ({
agentId,
programId,
input,
}: {
agentId: string
programId: string
input: UpdateAgentProgramInput
}) =>
clawFetch<{ program: AgentProgramEntry }>(
ensureBaseUrl(),
`/agents/${agentId}/programs/${programId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
},
),
onSuccess: async (_data, variables) =>
invalidateProgramsFor(variables.agentId),
})
const deleteProgramMutation = useMutation({
mutationFn: async ({
agentId,
programId,
}: {
agentId: string
programId: string
}) =>
clawFetch<{ success: boolean }>(
ensureBaseUrl(),
`/agents/${agentId}/programs/${programId}`,
{
method: 'DELETE',
},
),
onSuccess: async (_data, variables) =>
invalidateProgramsFor(variables.agentId),
})
const runProgramMutation = useMutation({
mutationFn: async ({
agentId,
programId,
}: {
agentId: string
programId: string
}) =>
clawFetch<{ run: AgentProgramRunEntry }>(
ensureBaseUrl(),
`/agents/${agentId}/programs/${programId}/run`,
{
method: 'POST',
},
),
onSuccess: async (_data, variables) =>
invalidateProgramsFor(variables.agentId),
})
return {
setupOpenClaw: setupMutation.mutateAsync,
@@ -246,6 +477,10 @@ export function useOpenClawMutations() {
stopOpenClaw: stopMutation.mutateAsync,
restartOpenClaw: restartMutation.mutateAsync,
reconnectOpenClaw: reconnectMutation.mutateAsync,
createProgram: createProgramMutation.mutateAsync,
updateProgram: updateProgramMutation.mutateAsync,
deleteProgram: deleteProgramMutation.mutateAsync,
runProgram: runProgramMutation.mutateAsync,
actionInProgress:
setupMutation.isPending ||
createMutation.isPending ||
@@ -253,12 +488,19 @@ export function useOpenClawMutations() {
startMutation.isPending ||
stopMutation.isPending ||
restartMutation.isPending ||
reconnectMutation.isPending,
reconnectMutation.isPending ||
createProgramMutation.isPending ||
updateProgramMutation.isPending ||
deleteProgramMutation.isPending ||
runProgramMutation.isPending,
settingUp: setupMutation.isPending,
creating: createMutation.isPending,
deleting: deleteMutation.isPending,
reconnecting: reconnectMutation.isPending,
pendingGatewayAction,
creatingProgram: createProgramMutation.isPending,
updatingProgram: updateProgramMutation.isPending,
deletingProgram: deleteProgramMutation.isPending,
runningProgram: runProgramMutation.isPending,
}
}

View File

@@ -9,8 +9,15 @@
import { accessSync, existsSync, constants as fsConstants } from 'node:fs'
import path from 'node:path'
import { OPENCLAW_GATEWAY_PORT } from '@browseros/shared/constants/openclaw'
import { BROWSEROS_ROLE_TEMPLATES } from '@browseros/shared/constants/role-aware-agents'
import type {
BrowserOSAgentRoleId,
BrowserOSCustomRoleInput,
} from '@browseros/shared/types/role-aware-agents'
import { Hono } from 'hono'
import { stream } from 'hono/streaming'
import { getOpenClawDir } from '../../lib/browseros-dir'
import { logger } from '../../lib/logger'
import { getMonitoringService } from '../../monitoring/service'
import type { MonitoringChatTurn } from '../../monitoring/types'
@@ -21,13 +28,32 @@ import {
OpenClawProtectedAgentError,
} from '../services/openclaw/errors'
import { isUnsupportedOpenClawProviderError } from '../services/openclaw/openclaw-provider-map'
import { getOpenClawService } from '../services/openclaw/openclaw-service'
import {
getOpenClawService,
type OpenClawAgentEntry,
} from '../services/openclaw/openclaw-service'
import { OpenClawProgramMaterializer } from '../services/openclaw/program-materializer'
import { OpenClawProgramStorage } from '../services/openclaw/program-storage'
import {
validateCreateProgramInput,
validateUpdateProgramInput,
} from '../services/openclaw/program-validation'
function getCreateAgentValidationError(body: { name?: string }): string | null {
if (!body.name?.trim()) {
return 'Name is required'
}
return null
function isValidBoundaryMode(
value: unknown,
): value is BrowserOSCustomRoleInput['boundaries'][number]['defaultMode'] {
return value === 'allow' || value === 'ask' || value === 'block'
}
function isValidCustomRoleBoundary(value: unknown): boolean {
if (!value || typeof value !== 'object') return false
const boundary = value as Record<string, unknown>
return (
typeof boundary.key === 'string' &&
typeof boundary.label === 'string' &&
typeof boundary.description === 'string' &&
isValidBoundaryMode(boundary.defaultMode)
)
}
function getPodmanOverrideValidationError(body: {
@@ -51,6 +77,19 @@ function getPodmanOverrideValidationError(body: {
return null
}
const openclawProgramStorage = new OpenClawProgramStorage(getOpenClawDir())
const openclawProgramMaterializer = new OpenClawProgramMaterializer(
getOpenClawDir(),
openclawProgramStorage,
)
async function findOpenClawAgent(
agentId: string,
): Promise<OpenClawAgentEntry | null> {
const agents = await getOpenClawService().listAgents()
return agents.find((agent) => agent.agentId === agentId) ?? null
}
export function createOpenClawRoutes() {
return new Hono()
.get('/status', async (c) => {
@@ -82,7 +121,7 @@ export function createOpenClawRoutes() {
return c.json(
{
status: 'running',
port: getOpenClawService().getPort(),
port: OPENCLAW_GATEWAY_PORT,
agents: agents.map((a) => ({
agentId: a.agentId,
name: a.name,
@@ -167,23 +206,240 @@ export function createOpenClawRoutes() {
}
})
.get('/agents/:id/programs', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const programs = await openclawProgramStorage.listPrograms(agent.name)
return c.json({ programs })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.post('/agents/:id/programs', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const input = validateCreateProgramInput(await c.req.json())
const program = await openclawProgramStorage.createProgram(agent, input)
await openclawProgramMaterializer.syncAgentPrograms(agent.name)
await getOpenClawService().refreshScheduledProgramsForAgent(agent.name)
return c.json({ program }, 201)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (
message.includes('required') ||
message.includes('must be') ||
message.includes('invalid')
) {
return c.json({ error: message }, 400)
}
return c.json({ error: message }, 500)
}
})
.patch('/agents/:id/programs/:programId', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const input = validateUpdateProgramInput(await c.req.json())
const program = await openclawProgramStorage.updateProgram(
agent.name,
c.req.param('programId'),
input,
)
if (!program) {
return c.json({ error: 'Program not found' }, 404)
}
await openclawProgramMaterializer.syncAgentPrograms(agent.name)
await getOpenClawService().refreshScheduledProgramsForAgent(agent.name)
return c.json({ program })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (
message.includes('required') ||
message.includes('must be') ||
message.includes('invalid') ||
message.includes('At least one')
) {
return c.json({ error: message }, 400)
}
return c.json({ error: message }, 500)
}
})
.delete('/agents/:id/programs/:programId', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const deleted = await openclawProgramStorage.deleteProgram(
agent.name,
c.req.param('programId'),
)
if (!deleted) {
return c.json({ error: 'Program not found' }, 404)
}
await openclawProgramMaterializer.syncAgentPrograms(agent.name)
await getOpenClawService().refreshScheduledProgramsForAgent(agent.name)
return c.json({ success: true })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.get('/agents/:id/program-runs', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const runs = await openclawProgramStorage.listRuns(agent.name)
return c.json({ runs })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.post('/agents/:id/programs/:programId/run', async (c) => {
try {
const agent = await findOpenClawAgent(c.req.param('id'))
if (!agent) {
return c.json({ error: 'Agent not found' }, 404)
}
const program = await openclawProgramStorage.getProgram(
agent.name,
c.req.param('programId'),
)
if (!program) {
return c.json({ error: 'Program not found' }, 404)
}
const run = await getOpenClawService().runProgramOnce(
agent.agentId,
program,
)
await openclawProgramMaterializer.syncAgentPrograms(agent.name)
await getOpenClawService().refreshScheduledProgramsForAgent(agent.name)
return c.json({ run })
} catch (err) {
if (err instanceof OpenClawAgentNotFoundError) {
return c.json({ error: err.message }, 404)
}
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.get('/roles', async (c) => {
return c.json({
roles: BROWSEROS_ROLE_TEMPLATES.map((role) => ({
id: role.id,
name: role.name,
shortDescription: role.shortDescription,
longDescription: role.longDescription,
recommendedApps: role.recommendedApps,
boundaries: role.boundaries,
defaultAgentName: role.defaultAgentName,
})),
})
})
.post('/agents', async (c) => {
const body = await c.req.json<{
name: string
roleId?: BrowserOSAgentRoleId
customRole?: BrowserOSCustomRoleInput
providerType?: string
providerName?: string
baseUrl?: string
apiKey?: string
modelId?: string
}>()
const validationError = getCreateAgentValidationError(body)
if (validationError) {
return c.json({ error: validationError }, 400)
const name = body.name?.trim()
if (!name) {
return c.json({ error: 'Name is required' }, 400)
}
if (body.roleId && body.customRole) {
return c.json(
{ error: 'Provide either roleId or customRole, not both' },
400,
)
}
if (
body.customRole &&
(!body.customRole.name?.trim() ||
!body.customRole.shortDescription?.trim() ||
!body.customRole.longDescription?.trim())
) {
return c.json(
{
error:
'Custom roles require name, shortDescription, and longDescription',
},
400,
)
}
if (
body.customRole &&
(!Array.isArray(body.customRole.recommendedApps) ||
!Array.isArray(body.customRole.boundaries))
) {
return c.json(
{
error: 'Custom roles require recommendedApps and boundaries arrays',
},
400,
)
}
if (
body.customRole &&
!body.customRole.recommendedApps.every((app) => typeof app === 'string')
) {
return c.json(
{
error: 'Custom role recommendedApps must be an array of strings',
},
400,
)
}
if (
body.customRole &&
!body.customRole.boundaries.every(isValidCustomRoleBoundary)
) {
return c.json(
{
error:
'Custom role boundaries must include key, label, description, and a valid defaultMode',
},
400,
)
}
try {
const agent = await getOpenClawService().createAgent({
name: body.name.trim(),
name,
roleId: body.roleId,
customRole: body.customRole,
providerType: body.providerType,
providerName: body.providerName,
baseUrl: body.baseUrl,

View File

@@ -6,10 +6,7 @@
* OpenClaw container lifecycle abstraction over PodmanRuntime.
*/
import {
OPENCLAW_GATEWAY_CONTAINER_NAME,
OPENCLAW_GATEWAY_CONTAINER_PORT,
} from '@browseros/shared/constants/openclaw'
import { OPENCLAW_GATEWAY_CONTAINER_NAME } from '@browseros/shared/constants/openclaw'
import { logger } from '../../../lib/logger'
import type { LogFn, PodmanRuntime } from './podman-runtime'
@@ -18,7 +15,7 @@ const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
export type GatewayContainerSpec = {
image: string
hostPort: number
port: number
hostHome: string
envFilePath: string
gatewayToken?: string
@@ -57,7 +54,6 @@ export class ContainerRuntime {
onLog?: LogFn,
): Promise<void> {
await this.ensureGatewayRemoved(onLog)
const containerPort = String(OPENCLAW_GATEWAY_CONTAINER_PORT)
const code = await this.runPodmanCommand(
[
'run',
@@ -67,10 +63,10 @@ export class ContainerRuntime {
'--restart',
'unless-stopped',
'-p',
`127.0.0.1:${input.hostPort}:${containerPort}`,
`127.0.0.1:${input.port}:18789`,
...this.buildGatewayContainerRuntimeArgs(input),
'--health-cmd',
`curl -sf http://127.0.0.1:${containerPort}/healthz`,
'curl -sf http://127.0.0.1:18789/healthz',
'--health-interval',
'30s',
'--health-timeout',
@@ -84,7 +80,7 @@ export class ContainerRuntime {
'--bind',
'lan',
'--port',
containerPort,
'18789',
'--allow-unconfigured',
],
onLog,
@@ -115,34 +111,31 @@ export class ContainerRuntime {
return lines
}
async isHealthy(hostPort: number): Promise<boolean> {
async isHealthy(port: number): Promise<boolean> {
try {
const res = await fetch(`http://127.0.0.1:${hostPort}/healthz`)
const res = await fetch(`http://127.0.0.1:${port}/healthz`)
return res.ok
} catch {
return false
}
}
async isReady(hostPort: number): Promise<boolean> {
async isReady(port: number): Promise<boolean> {
try {
const res = await fetch(`http://127.0.0.1:${hostPort}/readyz`)
const res = await fetch(`http://127.0.0.1:${port}/readyz`)
return res.ok
} catch {
return false
}
}
async waitForReady(hostPort: number, timeoutMs = 30_000): Promise<boolean> {
logger.info('Waiting for OpenClaw gateway readiness', {
hostPort,
timeoutMs,
})
async waitForReady(port: number, timeoutMs = 30_000): Promise<boolean> {
logger.info('Waiting for OpenClaw gateway readiness', { port, timeoutMs })
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (await this.isReady(hostPort)) {
if (await this.isReady(port)) {
logger.info('OpenClaw gateway became ready', {
hostPort,
port,
waitMs: Date.now() - start,
})
return true
@@ -150,7 +143,7 @@ export class ContainerRuntime {
await Bun.sleep(1000)
}
logger.error('Timed out waiting for OpenClaw gateway readiness', {
hostPort,
port,
timeoutMs,
})
return false

View File

@@ -22,7 +22,7 @@ export interface OpenClawChatRequest {
export class OpenClawHttpChatClient {
constructor(
private readonly hostPort: number,
private readonly port: 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.hostPort}/v1/chat/completions`,
`http://127.0.0.1:${this.port}/v1/chat/completions`,
{
method: 'POST',
headers: {

View File

@@ -37,6 +37,7 @@ export function resolveBundledPodmanPath(
export class PodmanRuntime {
private podmanPath: string
private machineReady = false
constructor(config?: { podmanPath?: string }) {
this.podmanPath = config?.podmanPath ?? 'podman'
@@ -137,9 +138,12 @@ 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) {
@@ -151,6 +155,8 @@ export class PodmanRuntime {
onLog?.('Starting Podman machine...')
await this.startMachine(onLog)
}
this.machineReady = true
}
async runCommand(

View File

@@ -0,0 +1,119 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { BrowserOSAgentProgram } from '@browseros/shared/types/role-programs'
import type { OpenClawProgramStorage } from './program-storage'
function describeSchedule(program: BrowserOSAgentProgram): string {
switch (program.schedule.type) {
case 'manual':
return 'manual only'
case 'daily': {
const weekdaySummary = program.schedule.daysOfWeek?.length
? ` on ${program.schedule.daysOfWeek.join(', ')}`
: ''
return `daily at ${program.schedule.time}${weekdaySummary}`
}
case 'hourly':
return `every ${program.schedule.interval} hour(s)`
case 'minutes':
return `every ${program.schedule.interval} minute(s)`
}
}
function buildProgramsMd(programs: BrowserOSAgentProgram[]): string {
const sections =
programs.length === 0
? ['No BrowserOS-managed programs configured yet.']
: programs.map(
(program) => `## ${program.name}
- Status: ${program.enabled ? 'enabled' : 'disabled'}
- Schedule: ${describeSchedule(program)}
- Next run: ${program.nextRunAt ?? 'not scheduled'}
- Goal: ${program.description}
- Prompt: ${program.prompt}
`,
)
return `# BrowserOS Programs
This file is generated by BrowserOS. Edit program settings in BrowserOS, not here.
${sections.join('\n')}
`
}
function buildStandingOrdersMd(programs: BrowserOSAgentProgram[]): string {
const sections = programs.flatMap((program) => {
if (program.standingOrders.length === 0) return []
const lines = program.standingOrders
.map(
(order) =>
`- ${order.title} (${order.enabled ? 'enabled' : 'disabled'}): ${order.instruction}`,
)
.join('\n')
return [`## ${program.name}\n${lines}`]
})
return `# Standing Orders
This file is generated by BrowserOS. Edit standing orders in BrowserOS, not here.
${sections.length > 0 ? sections.join('\n\n') : 'No standing orders configured yet.'}
`
}
function buildProgramsMetadata(
agentName: string,
programs: BrowserOSAgentProgram[],
): string {
return `${JSON.stringify(
{
version: 1,
agentName,
programs: programs.map((program) => ({
id: program.id,
name: program.name,
enabled: program.enabled,
schedule: program.schedule,
updatedAt: program.updatedAt,
lastRunAt: program.lastRunAt,
nextRunAt: program.nextRunAt,
})),
},
null,
2,
)}\n`
}
export class OpenClawProgramMaterializer {
constructor(
private openclawDir: string,
private storage: OpenClawProgramStorage,
) {}
private getHostWorkspaceDir(agentName: string): string {
return join(
this.openclawDir,
agentName === 'main' ? 'workspace' : `workspace-${agentName}`,
)
}
async syncAgentPrograms(agentName: string): Promise<void> {
const programs = await this.storage.listPrograms(agentName)
const workspaceDir = this.getHostWorkspaceDir(agentName)
await mkdir(workspaceDir, { recursive: true })
await Promise.all([
writeFile(join(workspaceDir, 'PROGRAMS.md'), buildProgramsMd(programs)),
writeFile(
join(workspaceDir, 'STANDING-ORDERS.md'),
buildStandingOrdersMd(programs),
),
writeFile(
join(workspaceDir, '.browseros-programs.json'),
buildProgramsMetadata(agentName, programs),
),
])
}
}

View File

@@ -0,0 +1,118 @@
import type {
BrowserOSAgentProgram,
BrowserOSProgramSchedule,
} from '@browseros/shared/types/role-programs'
const MINUTE_MS = 60_000
const HOUR_MS = 60 * MINUTE_MS
function toValidDate(value?: string): Date | null {
if (!value) return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
function getIntervalMs(
schedule: Extract<BrowserOSProgramSchedule, { type: 'minutes' | 'hourly' }>,
): number {
return schedule.type === 'minutes'
? schedule.interval * MINUTE_MS
: schedule.interval * HOUR_MS
}
function getAnchorDate(program: BrowserOSAgentProgram, now: Date): Date {
return (
toValidDate(program.lastRunAt) ??
toValidDate(program.updatedAt) ??
toValidDate(program.createdAt) ??
now
)
}
function getNextIntervalRunAt(
program: BrowserOSAgentProgram,
now: Date,
): Date | null {
const schedule = program.schedule
if (schedule.type !== 'minutes' && schedule.type !== 'hourly') return null
const intervalMs = getIntervalMs(schedule)
if (intervalMs <= 0) return null
const anchor = getAnchorDate(program, now)
let nextRunAt = new Date(anchor.getTime() + intervalMs)
while (nextRunAt.getTime() <= now.getTime()) {
nextRunAt = new Date(nextRunAt.getTime() + intervalMs)
}
return nextRunAt
}
function getNextDailyRunAt(
program: BrowserOSAgentProgram,
now: Date,
): Date | null {
if (program.schedule.type !== 'daily') return null
const [hoursString, minutesString] = program.schedule.time.split(':')
const hours = Number.parseInt(hoursString ?? '', 10)
const minutes = Number.parseInt(minutesString ?? '', 10)
if (
Number.isNaN(hours) ||
Number.isNaN(minutes) ||
hours < 0 ||
hours > 23 ||
minutes < 0 ||
minutes > 59
) {
return null
}
const allowedDays = program.schedule.daysOfWeek?.length
? new Set(program.schedule.daysOfWeek)
: null
for (let offset = 0; offset < 8; offset += 1) {
const candidate = new Date(now)
candidate.setDate(now.getDate() + offset)
candidate.setHours(hours, minutes, 0, 0)
if (
allowedDays &&
!allowedDays.has(candidate.getDay() as 0 | 1 | 2 | 3 | 4 | 5 | 6)
) {
continue
}
if (candidate.getTime() > now.getTime()) {
return candidate
}
}
return null
}
export function isSchedulableProgram(program: BrowserOSAgentProgram): boolean {
return program.schedule.type !== 'manual'
}
export function getNextProgramRunAt(
program: BrowserOSAgentProgram,
now = new Date(),
): Date | null {
if (!program.enabled || !isSchedulableProgram(program)) {
return null
}
switch (program.schedule.type) {
case 'minutes':
case 'hourly':
return getNextIntervalRunAt(program, now)
case 'daily':
return getNextDailyRunAt(program, now)
case 'manual':
return null
}
}

View File

@@ -0,0 +1,212 @@
import type {
BrowserOSAgentProgram,
BrowserOSProgramRun,
} from '@browseros/shared/types/role-programs'
import { logger } from '../../../lib/logger'
import type { OpenClawProgramMaterializer } from './program-materializer'
import { getNextProgramRunAt, isSchedulableProgram } from './program-schedule'
import type { OpenClawProgramStorage } from './program-storage'
interface ScheduledProgramHandle {
programId: string
agentId: string
agentName: string
timeout: ReturnType<typeof setTimeout>
nextRunAt: string
}
export interface OpenClawSchedulerSnapshot {
running: boolean
activeProgramCount: number
}
export class OpenClawProgramScheduler {
private handles = new Map<string, ScheduledProgramHandle>()
private running = false
constructor(
private programStorage: OpenClawProgramStorage,
private programMaterializer: OpenClawProgramMaterializer,
private runProgram: (
agentId: string,
program: BrowserOSAgentProgram,
trigger: 'manual' | 'schedule' | 'retry',
) => Promise<BrowserOSProgramRun>,
private listAgents: () => Promise<Array<{ agentId: string; name: string }>>,
) {}
getSnapshot(): OpenClawSchedulerSnapshot {
return {
running: this.running,
activeProgramCount: this.handles.size,
}
}
async start(): Promise<void> {
this.running = true
await this.rehydrate()
}
async stop(): Promise<void> {
this.running = false
this.clearAllHandles()
}
async rehydrate(): Promise<void> {
this.clearAllHandles()
if (!this.running) return
const agents = await this.listAgents()
for (const agent of agents) {
await this.refreshAgent(agent.name)
}
}
async refreshAgent(agentName: string): Promise<void> {
this.clearAgentHandles(agentName)
const programs = await this.programStorage.listPrograms(agentName)
let materialized = false
for (const program of programs) {
const changed = await this.syncProgram(program)
materialized = materialized || changed
}
if (materialized) {
await this.programMaterializer.syncAgentPrograms(agentName)
}
}
async refreshProgram(agentName: string, programId: string): Promise<void> {
this.clearProgramHandle(programId)
const program = await this.programStorage.getProgram(agentName, programId)
const changed = program ? await this.syncProgram(program) : false
if (changed) {
await this.programMaterializer.syncAgentPrograms(agentName)
}
}
async removeProgram(programId: string): Promise<void> {
this.clearProgramHandle(programId)
}
private clearAllHandles(): void {
for (const handle of this.handles.values()) {
clearTimeout(handle.timeout)
}
this.handles.clear()
}
private clearAgentHandles(agentName: string): void {
for (const [programId, handle] of this.handles.entries()) {
if (handle.agentName !== agentName) continue
clearTimeout(handle.timeout)
this.handles.delete(programId)
}
}
private clearProgramHandle(programId: string): void {
const handle = this.handles.get(programId)
if (!handle) return
clearTimeout(handle.timeout)
this.handles.delete(programId)
}
private async syncProgram(program: BrowserOSAgentProgram): Promise<boolean> {
if (!this.running) {
return false
}
if (!program.enabled || !isSchedulableProgram(program)) {
if (!program.nextRunAt) return false
await this.programStorage.updateProgram(
program.agentName,
program.id,
{ nextRunAt: undefined },
{ touchUpdatedAt: false },
)
return true
}
const nextRunAt = getNextProgramRunAt(program)
const nextRunAtIso = nextRunAt?.toISOString()
let changed = false
if (program.nextRunAt !== nextRunAtIso) {
await this.programStorage.updateProgram(
program.agentName,
program.id,
{ nextRunAt: nextRunAtIso },
{ touchUpdatedAt: false },
)
changed = true
}
if (!nextRunAt) {
return changed
}
const delayMs = Math.max(1000, nextRunAt.getTime() - Date.now())
const timeout = setTimeout(() => {
void this.executeScheduledProgram({
agentId: program.agentId,
agentName: program.agentName,
programId: program.id,
})
}, delayMs)
this.handles.set(program.id, {
programId: program.id,
agentId: program.agentId,
agentName: program.agentName,
timeout,
nextRunAt: nextRunAtIso ?? program.nextRunAt ?? new Date().toISOString(),
})
return changed
}
private async executeScheduledProgram(input: {
agentId: string
agentName: string
programId: string
}): Promise<void> {
this.handles.delete(input.programId)
if (!this.running) return
const program = await this.programStorage.getProgram(
input.agentName,
input.programId,
)
if (!program || !program.enabled || !isSchedulableProgram(program)) {
await this.refreshProgram(input.agentName, input.programId)
return
}
try {
await this.runProgram(input.agentId, program, 'schedule')
logger.info('Scheduled program run completed', {
agentId: input.agentId,
agentName: input.agentName,
programId: input.programId,
})
} catch (error) {
logger.error('Scheduled program run failed', {
agentId: input.agentId,
agentName: input.agentName,
programId: input.programId,
error: error instanceof Error ? error.message : String(error),
})
} finally {
if (this.running) {
await this.refreshProgram(input.agentName, input.programId)
}
}
}
}

View File

@@ -0,0 +1,188 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type {
BrowserOSAgentProgram,
BrowserOSProgramRun,
CreateAgentProgramInput,
UpdateAgentProgramInput,
} from '@browseros/shared/types/role-programs'
interface ProgramStorageAgent {
agentId: string
name: string
role?: {
roleId?: string
}
}
interface UpdateProgramOptions {
touchUpdatedAt?: boolean
}
const MAX_PROGRAM_RUNS_PER_AGENT = 100
async function readJsonFile<T>(filePath: string, fallback: T): Promise<T> {
try {
const content = await readFile(filePath, 'utf-8')
return JSON.parse(content) as T
} catch {
return fallback
}
}
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
function sortPrograms(programs: BrowserOSAgentProgram[]) {
return [...programs].sort((left, right) =>
left.createdAt.localeCompare(right.createdAt),
)
}
function normalizeRuns(runs: BrowserOSProgramRun[]) {
return [...runs]
.sort((left, right) => right.startedAt.localeCompare(left.startedAt))
.slice(0, MAX_PROGRAM_RUNS_PER_AGENT)
}
export class OpenClawProgramStorage {
constructor(private openclawDir: string) {}
private getProgramsFile(agentName: string): string {
return join(this.openclawDir, 'programs', `${agentName}.json`)
}
private getProgramRunsFile(agentName: string): string {
return join(this.openclawDir, 'program-runs', `${agentName}.json`)
}
async listPrograms(agentName: string): Promise<BrowserOSAgentProgram[]> {
const programs = await readJsonFile<BrowserOSAgentProgram[]>(
this.getProgramsFile(agentName),
[],
)
return sortPrograms(programs)
}
async getProgram(
agentName: string,
programId: string,
): Promise<BrowserOSAgentProgram | null> {
const programs = await this.listPrograms(agentName)
return programs.find((program) => program.id === programId) ?? null
}
async createProgram(
agent: ProgramStorageAgent,
input: CreateAgentProgramInput,
): Promise<BrowserOSAgentProgram> {
const programs = await this.listPrograms(agent.name)
const now = new Date().toISOString()
const program: BrowserOSAgentProgram = {
id: crypto.randomUUID(),
agentId: agent.agentId,
agentName: agent.name,
roleId: agent.role?.roleId,
name: input.name,
description: input.description,
prompt: input.prompt,
schedule: input.schedule,
enabled: input.enabled ?? true,
standingOrders: input.standingOrders ?? [],
createdAt: now,
updatedAt: now,
}
await writeJsonFile(this.getProgramsFile(agent.name), [
...programs,
program,
])
return program
}
async updateProgram(
agentName: string,
programId: string,
input: UpdateAgentProgramInput,
options?: UpdateProgramOptions,
): Promise<BrowserOSAgentProgram | null> {
const programs = await this.listPrograms(agentName)
const current = programs.find((program) => program.id === programId)
if (!current) return null
const nextProgram: BrowserOSAgentProgram = {
...current,
...input,
updatedAt:
options?.touchUpdatedAt === false
? current.updatedAt
: new Date().toISOString(),
}
await writeJsonFile(
this.getProgramsFile(agentName),
programs.map((program) =>
program.id === programId ? nextProgram : program,
),
)
return nextProgram
}
async deleteProgram(agentName: string, programId: string): Promise<boolean> {
const programs = await this.listPrograms(agentName)
const remaining = programs.filter((program) => program.id !== programId)
if (remaining.length === programs.length) return false
await writeJsonFile(this.getProgramsFile(agentName), remaining)
return true
}
async listRuns(agentName: string): Promise<BrowserOSProgramRun[]> {
const runs = await readJsonFile<BrowserOSProgramRun[]>(
this.getProgramRunsFile(agentName),
[],
)
return normalizeRuns(runs)
}
async writeRuns(
agentName: string,
runs: BrowserOSProgramRun[],
): Promise<void> {
await writeJsonFile(this.getProgramRunsFile(agentName), normalizeRuns(runs))
}
async appendRun(
agentName: string,
run: BrowserOSProgramRun,
): Promise<BrowserOSProgramRun> {
const runs = await this.listRuns(agentName)
await this.writeRuns(agentName, [run, ...runs])
return run
}
async updateRun(
agentName: string,
runId: string,
input: Partial<BrowserOSProgramRun>,
): Promise<BrowserOSProgramRun | null> {
const runs = await this.listRuns(agentName)
const current = runs.find((run) => run.id === runId)
if (!current) return null
const nextRun: BrowserOSProgramRun = {
...current,
...input,
}
await this.writeRuns(
agentName,
runs.map((run) => (run.id === runId ? nextRun : run)),
)
return nextRun
}
}

View File

@@ -0,0 +1,179 @@
import type {
BrowserOSProgramSchedule,
BrowserOSStandingOrder,
CreateAgentProgramInput,
UpdateAgentProgramInput,
} from '@browseros/shared/types/role-programs'
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function assertNonEmptyString(
value: unknown,
field: string,
): asserts value is string {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`${field} is required`)
}
}
function validateStandingOrder(value: unknown): BrowserOSStandingOrder {
if (!isRecord(value)) {
throw new Error('Standing orders must be objects')
}
assertNonEmptyString(value.title, 'Standing order title')
assertNonEmptyString(value.instruction, 'Standing order instruction')
if (typeof value.enabled !== 'boolean') {
throw new Error('Standing order enabled must be a boolean')
}
return {
id:
typeof value.id === 'string' && value.id.trim() !== ''
? value.id
: crypto.randomUUID(),
title: value.title.trim(),
instruction: value.instruction.trim(),
enabled: value.enabled,
}
}
function validateStandingOrders(
value: unknown,
): BrowserOSStandingOrder[] | undefined {
if (value === undefined) return undefined
if (!Array.isArray(value)) {
throw new Error('standingOrders must be an array')
}
return value.map(validateStandingOrder)
}
function isValidTime(value: string): boolean {
return /^([01]\d|2[0-3]):[0-5]\d$/.test(value)
}
function validateDaysOfWeek(value: unknown): Array<0 | 1 | 2 | 3 | 4 | 5 | 6> {
if (!Array.isArray(value)) {
throw new Error('schedule.daysOfWeek must be an array')
}
return value.map((day) => {
if (
typeof day !== 'number' ||
!Number.isInteger(day) ||
day < 0 ||
day > 6
) {
throw new Error('schedule.daysOfWeek must contain values from 0 to 6')
}
return day as 0 | 1 | 2 | 3 | 4 | 5 | 6
})
}
function validateSchedule(value: unknown): BrowserOSProgramSchedule {
if (!isRecord(value) || typeof value.type !== 'string') {
throw new Error('schedule is required')
}
switch (value.type) {
case 'manual':
return { type: 'manual' }
case 'daily': {
assertNonEmptyString(value.time, 'schedule.time')
if (!isValidTime(value.time)) {
throw new Error('schedule.time must be in HH:MM format')
}
return {
type: 'daily',
time: value.time,
daysOfWeek:
value.daysOfWeek === undefined
? undefined
: validateDaysOfWeek(value.daysOfWeek),
}
}
case 'hourly':
case 'minutes': {
if (
typeof value.interval !== 'number' ||
!Number.isInteger(value.interval) ||
value.interval < 1
) {
throw new Error('schedule.interval must be an integer >= 1')
}
return {
type: value.type,
interval: value.interval,
}
}
default:
throw new Error('schedule.type is invalid')
}
}
export function validateCreateProgramInput(
value: unknown,
): CreateAgentProgramInput {
if (!isRecord(value)) {
throw new Error('Program payload must be an object')
}
assertNonEmptyString(value.name, 'name')
assertNonEmptyString(value.description, 'description')
assertNonEmptyString(value.prompt, 'prompt')
return {
name: value.name.trim(),
description: value.description.trim(),
prompt: value.prompt.trim(),
schedule: validateSchedule(value.schedule),
enabled: value.enabled === undefined ? true : !!value.enabled,
standingOrders: validateStandingOrders(value.standingOrders) ?? [],
}
}
export function validateUpdateProgramInput(
value: unknown,
): UpdateAgentProgramInput {
if (!isRecord(value)) {
throw new Error('Program payload must be an object')
}
const output: UpdateAgentProgramInput = {}
if (value.name !== undefined) {
assertNonEmptyString(value.name, 'name')
output.name = value.name.trim()
}
if (value.description !== undefined) {
assertNonEmptyString(value.description, 'description')
output.description = value.description.trim()
}
if (value.prompt !== undefined) {
assertNonEmptyString(value.prompt, 'prompt')
output.prompt = value.prompt.trim()
}
if (value.enabled !== undefined) {
if (typeof value.enabled !== 'boolean') {
throw new Error('enabled must be a boolean')
}
output.enabled = value.enabled
}
if (value.schedule !== undefined) {
output.schedule = validateSchedule(value.schedule)
}
if (value.standingOrders !== undefined) {
output.standingOrders = validateStandingOrders(value.standingOrders)
}
if (Object.keys(output).length === 0) {
throw new Error('At least one program field must be provided')
}
return output
}

View File

@@ -0,0 +1,200 @@
import {
type BROWSEROS_ROLE_TEMPLATES,
getBrowserOSRoleTemplate,
} from '@browseros/shared/constants/role-aware-agents'
import type {
BrowserOSAgentRoleId,
BrowserOSAgentRoleSummary,
BrowserOSCustomRoleInput,
BrowserOSRoleTemplate,
} from '@browseros/shared/types/role-aware-agents'
type RoleTemplate = (typeof BROWSEROS_ROLE_TEMPLATES)[number]
interface BootstrapRenderableRole {
name: string
shortDescription: string
longDescription: string
recommendedApps: string[]
boundaries: BrowserOSRoleTemplate['boundaries']
bootstrap: BrowserOSRoleTemplate['bootstrap']
}
export interface RoleBootstrapFiles {
'AGENTS.md': string
'SOUL.md': string
'TOOLS.md': string
'.browseros-role.json': string
}
export function resolveRoleTemplate(
roleId: BrowserOSAgentRoleId,
): RoleTemplate {
const role = getBrowserOSRoleTemplate(roleId)
if (!role) {
throw new Error(`Unknown BrowserOS role: ${roleId}`)
}
return role
}
export function buildRoleBootstrapFiles(input: {
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput
agentName: string
}): RoleBootstrapFiles {
const normalizedRole = normalizeRoleForBootstrap(input.role)
const roleId = 'id' in input.role ? input.role.id : undefined
return {
'AGENTS.md': normalizedRole.bootstrap.agentsMd,
'SOUL.md': normalizedRole.bootstrap.soulMd,
'TOOLS.md': normalizedRole.bootstrap.toolsMd,
'.browseros-role.json': `${JSON.stringify(
{
version: 1,
roleSource: roleId ? 'builtin' : 'custom',
roleId,
roleName: normalizedRole.name,
shortDescription: normalizedRole.shortDescription,
createdBy: 'browseros',
agentName: input.agentName,
},
null,
2,
)}\n`,
}
}
export function toRoleSummary(
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput,
): BrowserOSAgentRoleSummary {
const normalizedRole = normalizeRoleForBootstrap(role)
return {
roleSource: 'id' in role ? 'builtin' : 'custom',
roleId: 'id' in role ? role.id : undefined,
roleName: normalizedRole.name,
shortDescription: normalizedRole.shortDescription,
}
}
export function normalizeCustomRole(
role: BrowserOSCustomRoleInput,
): BootstrapRenderableRole {
const recommendedApps = Array.isArray(role.recommendedApps)
? role.recommendedApps.filter(
(app): app is string => typeof app === 'string',
)
: []
const boundaries = Array.isArray(role.boundaries) ? role.boundaries : []
return {
name: role.name,
shortDescription: role.shortDescription,
longDescription: role.longDescription,
recommendedApps,
boundaries,
bootstrap: {
agentsMd:
role.bootstrap?.agentsMd?.trim() ||
buildAgentsMd({
name: role.name,
longDescription: role.longDescription,
boundaries,
}),
soulMd:
role.bootstrap?.soulMd?.trim() ||
buildSoulMd({
name: role.name,
shortDescription: role.shortDescription,
longDescription: role.longDescription,
}),
toolsMd:
role.bootstrap?.toolsMd?.trim() ||
buildToolsMd({
boundaries,
recommendedApps,
}),
},
}
}
function normalizeRoleForBootstrap(
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput,
): BootstrapRenderableRole {
return 'id' in role ? role : normalizeCustomRole(role)
}
function buildAgentsMd(input: {
name: string
longDescription: string
boundaries: BrowserOSRoleTemplate['boundaries']
}): string {
const boundaryLines = input.boundaries
.map(
(boundary) =>
`- ${boundary.label}: ${boundary.description} Default mode: ${boundary.defaultMode}.`,
)
.join('\n')
return `# ${input.name}
You are the ${input.name} specialist for this workspace.
## Core Purpose
${input.longDescription}
## Operating Rules
${boundaryLines}
## Default Output Style
- concise
- action-oriented
- explicit about blockers and approvals
`
}
function buildSoulMd(input: {
name: string
shortDescription: string
longDescription: string
}): string {
return `# Operating Style
You act like a trusted ${input.name}.
## Working Posture
- calm
- structured
- direct
- explicit about tradeoffs
## Role Framing
${input.shortDescription}
${input.longDescription}
`
}
function buildToolsMd(input: {
boundaries: BrowserOSRoleTemplate['boundaries']
recommendedApps: string[]
}): string {
const boundaryLines = input.boundaries
.map((boundary) => `- ${boundary.label}: ${boundary.defaultMode}`)
.join('\n')
const appsLine =
input.recommendedApps.length > 0
? input.recommendedApps.join(', ')
: 'No specific apps configured yet.'
return `# Tooling Guidelines
- Use BrowserOS MCP for browser and connected SaaS tasks.
- Prefer read, summarize, and draft flows.
- Keep outputs in the workspace when possible so work remains inspectable.
## Recommended Apps
${appsLine}
## Boundary Defaults
${boundaryLines}
`
}

View File

@@ -1,97 +0,0 @@
/**
* @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
}

View File

@@ -6,10 +6,12 @@ import { PATHS } from '@browseros/shared/constants/paths'
import type { ServerDiscoveryConfig } from '@browseros/shared/types/server-config'
import { logger } from './logger'
const DEV_BROWSEROS_DIR_NAME = '.browseros-dev'
export function getBrowserosDir(): string {
const dirName =
process.env.NODE_ENV === 'development'
? PATHS.DEV_BROWSEROS_DIR_NAME
? DEV_BROWSEROS_DIR_NAME
: PATHS.BROWSEROS_DIR_NAME
return join(homedir(), dirName)
}
@@ -47,18 +49,6 @@ 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')
}
@@ -96,7 +86,6 @@ export async function ensureBrowserosDir(): Promise<void> {
await mkdir(getBuiltinSkillsDir(), { recursive: true })
await mkdir(getSessionsDir(), { recursive: true })
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
await mkdir(getAgentCacheDir(), { recursive: true })
}
export async function cleanOldSessions(): Promise<void> {

View File

@@ -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',
hostPort: 18789,
port: 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.hostPort}:18789`,
`127.0.0.1:${spec.port}:18789`,
...expectedGatewayRuntimeArgs(spec),
'--health-cmd',
'curl -sf http://127.0.0.1:18789/healthz',

View File

@@ -6,7 +6,6 @@
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'
@@ -24,8 +23,7 @@ type MutableOpenClawService = OpenClawService & {
ensureReady?: () => Promise<void>
isPodmanAvailable?: () => Promise<boolean>
getMachineStatus?: () => Promise<{ initialized: boolean; running: boolean }>
isHealthy?: (_hostPort?: number) => Promise<boolean>
isReady: (_hostPort?: number) => Promise<boolean>
isReady: () => Promise<boolean>
pullImage?: (
_image: string,
_onLog?: (_line: string) => void,
@@ -294,7 +292,7 @@ describe('OpenClawService', () => {
expect(startGateway).toHaveBeenCalledWith(
expect.objectContaining({
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
hostPort: expect.any(Number),
port: 18789,
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
gatewayToken: undefined,
@@ -575,7 +573,7 @@ describe('OpenClawService', () => {
service.openclawDir = tempDir
service.runtime = {
ensureReady,
isReady: async () => false,
isReady: async () => true,
startGateway,
waitForReady,
}
@@ -589,7 +587,7 @@ describe('OpenClawService', () => {
expect(startGateway).toHaveBeenCalledWith(
expect.objectContaining({
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
hostPort: expect.any(Number),
port: 18789,
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
gatewayToken: 'cli-token',
@@ -600,99 +598,6 @@ 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 })
@@ -726,7 +631,7 @@ describe('OpenClawService', () => {
expect(restartGateway).toHaveBeenCalledWith(
expect.objectContaining({
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
hostPort: expect.any(Number),
port: 18789,
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
gatewayToken: 'cli-token',
@@ -737,72 +642,6 @@ 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
@@ -904,7 +743,7 @@ describe('OpenClawService', () => {
expect(startGateway).toHaveBeenCalledWith(
expect.objectContaining({
image: 'ghcr.io/openclaw/openclaw:2026.4.12',
hostPort: expect.any(Number),
port: 18789,
hostHome: tempDir,
envFilePath: join(tempDir, '.openclaw', '.env'),
gatewayToken: 'cli-token',
@@ -912,7 +751,7 @@ describe('OpenClawService', () => {
)
expect(waitForReady).toHaveBeenCalledTimes(1)
expect(probe).toHaveBeenCalledTimes(1)
expect(isReady).toHaveBeenCalledTimes(2)
expect(isReady).toHaveBeenCalledTimes(1)
})
it('keeps openrouter model refs verbatim without rewriting dots', () => {

View File

@@ -11,43 +11,9 @@ 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
@@ -114,56 +80,4 @@ 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)
})
})

View File

@@ -8,10 +8,7 @@ import { homedir } from 'node:os'
import { join } from 'node:path'
import { PATHS } from '@browseros/shared/constants/paths'
import {
getAgentCacheDir,
getBrowserosDir,
getCacheDir,
getVmCacheDir,
logDevelopmentBrowserosDir,
} from '../src/lib/browseros-dir'
import { logger } from '../src/lib/logger'
@@ -75,34 +72,4 @@ describe('getBrowserosDir', () => {
logger.info = originalInfo
}
})
it('uses the development cache directory in development', () => {
process.env.NODE_ENV = 'development'
expect(getCacheDir()).toBe(join(homedir(), '.browseros-dev', 'cache'))
})
it('uses the standard cache directory outside development', () => {
process.env.NODE_ENV = 'test'
expect(getCacheDir()).toBe(
join(homedir(), PATHS.BROWSEROS_DIR_NAME, 'cache'),
)
})
it('uses a vm cache directory below cache', () => {
process.env.NODE_ENV = 'development'
expect(getVmCacheDir()).toBe(
join(homedir(), '.browseros-dev', 'cache', 'vm'),
)
})
it('uses an agent image cache directory below vm cache', () => {
process.env.NODE_ENV = 'development'
expect(getAgentCacheDir()).toBe(
join(homedir(), '.browseros-dev', 'cache', 'vm', 'images'),
)
})
})

View File

@@ -232,17 +232,6 @@
"zod": "^3.x",
},
},
"packages/build-tools": {
"name": "@browseros/build-tools",
"version": "0.0.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.933.0",
"@browseros/shared": "workspace:*",
},
"devDependencies": {
"@types/node": "^24.3.3",
},
},
"packages/cdp-protocol": {
"name": "@browseros/cdp-protocol",
"version": "0.0.1",
@@ -474,8 +463,6 @@
"@browseros/agent": ["@browseros/agent@workspace:apps/agent"],
"@browseros/build-tools": ["@browseros/build-tools@workspace:packages/build-tools"],
"@browseros/cdp-protocol": ["@browseros/cdp-protocol@workspace:packages/cdp-protocol"],
"@browseros/eval": ["@browseros/eval@workspace:apps/eval"],
@@ -4502,8 +4489,6 @@
"@browseros/agent/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"@browseros/build-tools/@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1014.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-node": "^3.972.24", "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", "@aws-sdk/middleware-expect-continue": "^3.972.8", "@aws-sdk/middleware-flexible-checksums": "^3.974.3", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-location-constraint": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/middleware-ssec": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/signature-v4-multi-region": "^3.996.11", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-blob-browser": "^4.2.13", "@smithy/hash-node": "^4.2.12", "@smithy/hash-stream-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/md5-js": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-0XLrOT4Cm3NEhhiME7l/8LbTXS4KdsbR4dSrY207KNKTcHLLTZ9EXt4ZpgnTfLvWQF3pGP2us4Zi1fYLo0N+Ow=="],
"@browseros/eval/@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1014.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-node": "^3.972.24", "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", "@aws-sdk/middleware-expect-continue": "^3.972.8", "@aws-sdk/middleware-flexible-checksums": "^3.974.3", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-location-constraint": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/middleware-ssec": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/signature-v4-multi-region": "^3.996.11", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-blob-browser": "^4.2.13", "@smithy/hash-node": "^4.2.12", "@smithy/hash-stream-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/md5-js": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" } }, "sha512-0XLrOT4Cm3NEhhiME7l/8LbTXS4KdsbR4dSrY207KNKTcHLLTZ9EXt4ZpgnTfLvWQF3pGP2us4Zi1fYLo0N+Ow=="],
"@browseros/eval/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
@@ -5228,100 +5213,6 @@
"@browseros/agent/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.24", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-ini": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/crc64-nvme": "^3.972.5", "@aws-sdk/types": "^3.973.6", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-fB7FNLH1+VPUs0QL3PLrHW+DD4gKu6daFgWtyq3R0Y0Lx8DLZPvyGAxCZNFBxH+M2xt9KvBJX6USwjuqvitmCQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-50QgHGPQAb2veqFOmTF1A3GsAklLHZXL47KbY35khIkfbXH5PLvqpEc/gOAEBPj/yFxrlgxz/8mqWcWTNxBkwQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-dLTWy6IfAMhNiSEvMr07g/qZ54be6pLqlxVblbF6AzafmmGAzMMj8qMoY9B4+YgT+gY9IcuxZslNh03L6PyMCQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/config-resolver": "^4.4.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.11", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-SKgZY7x6AloLUXO20FJGnkKJ3a6CXzNDt6PYs2yqoPzgU0xKWcUoGGJGEBTsfM5eihKW42lbwp+sXzACLbSsaA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.10", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-E99zeTscCc+pTMfsvnfi6foPpKmdD1cZfOC7/P8UUrjsoQdg9VEWPRD+xdFduKnfPXwcvby58AlO9jwwF6U96g=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/config-resolver": ["@smithy/config-resolver@4.4.13", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/core": ["@smithy/core@3.23.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.15", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.13", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/hash-node": ["@smithy/hash-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/md5-js": ["@smithy/md5-js@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.27", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-serde": "^4.2.15", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.44", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/service-error-classification": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.15", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.12", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.0", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/protocol-http": ["@smithy/protocol-http@5.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/smithy-client": ["@smithy/smithy-client@4.12.7", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-stack": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/url-parser": ["@smithy/url-parser@4.2.12", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.43", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.47", "", { "dependencies": { "@smithy/config-resolver": "^4.4.13", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-middleware": ["@smithy/util-middleware@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-retry": ["@smithy/util-retry@4.2.12", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-stream": ["@smithy/util-stream@4.5.20", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-waiter": ["@smithy/util-waiter@4.2.13", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core": ["@aws-sdk/core@3.973.23", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.24", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-ini": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw=="],
@@ -5706,70 +5597,6 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BkAfKq8Bd4shCtec1usNz//urPJF/SZy14qJyxkSaRJQ/Vv1gVh0VZSTmS7aE6aLMELkFV5wHHrS9ZcdG8Kxsg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-4XZ3+Gu5DY8/n8zQFHBgcKTF7hWQl42G6CY9xfXVo2d25FM/lYkpmuzhYopYoPL1ITWkJ2OSBQfYEu5JRfHOhA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/credential-provider-env": "^3.972.21", "@aws-sdk/credential-provider-http": "^3.972.23", "@aws-sdk/credential-provider-login": "^3.972.23", "@aws-sdk/credential-provider-process": "^3.972.21", "@aws-sdk/credential-provider-sso": "^3.972.23", "@aws-sdk/credential-provider-web-identity": "^3.972.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-PZLSmU0JFpNCDFReidBezsgL5ji9jOBry8CnZdw4Jj6d0K2z3Ftnp44NXgADqYx5BLMu/ZHujfeJReaDoV+IwQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-nRxbeOJ1E1gVA0lNQezuMVndx+ZcuyaW/RB05pUsznN5BxykSlH6KkZ/7Ca/ubJf3i5N3p0gwNO5zgPSCzj+ww=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/token-providers": "3.1014.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-APUccADuYPLL0f2htpM8Z4czabSmHOdo4r41W6lKEZdy++cNJ42Radqy6x4TopENzr3hR6WYMyhiuiqtbf/nAA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-H5JNqtIwOu/feInmMMWcK0dL5r897ReEn7n2m16Dd0DPD9gA2Hg8Cq4UDzZ/9OzaLh/uqBM6seixz0U6Fi2Eag=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-bucket-endpoint/@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-flexible-checksums/@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.5", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3/@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/middleware-sdk-s3/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/signature-v4-multi-region/@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-browser/@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-node/@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/fetch-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-endpoint/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/middleware-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-config-provider/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-config-provider/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-http-handler/@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/node-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/url-parser/@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-browser/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-node/@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-defaults-mode-node/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/util-waiter/@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
@@ -5878,22 +5705,6 @@
"wxt/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1014.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-gHTHNUoaOGNrSWkl32A7wFsU78jlNTlqMccLu0byUk5CysYYXaxNMIonIVr4YcykC7vgtDS5ABuz83giy6fzJA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.23", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.24", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.10", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-browser/@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
"@browseros/build-tools/@aws-sdk/client-s3/@smithy/eventstream-serde-node/@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.23", "@aws-sdk/nested-clients": "^3.996.13", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg=="],
@@ -5922,8 +5733,6 @@
"publish-browser-extension/listr2/cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"@browseros/build-tools/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
"@browseros/eval/@aws-sdk/client-s3/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
"@google/genai/google-auth-library/gaxios/rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],

View File

@@ -1,11 +0,0 @@
# R2 / Cloudflare object storage - required by upload and publish jobs
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=browseros-artifacts
# 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

View File

@@ -1,57 +0,0 @@
# @browseros/build-tools
Builds BrowserOS VM disks and agent image tarballs, publishes release artifacts to R2, and hydrates the local VM artifact cache for development.
## Setup
```bash
cp packages/build-tools/.env.sample packages/build-tools/.env
bun install
```
## Build a VM disk
Requires `libguestfs`, `qemu-img`, and `zstd` in an arm64 Linux environment.
On Apple Silicon, run this from an arm64 Lima/Debian VM rather than directly on macOS.
```bash
bun run --filter @browseros/build-tools build:disk -- --version 2026.04.22 --arch arm64
```
## Build an agent tarball
Requires `podman`.
```bash
bun run --filter @browseros/build-tools build:tarball -- --agent openclaw --arch arm64
```
## Smoke test artifacts
VM smoke tests require `limactl`, `qemu`, and `zstd`. Agent tarball smoke tests require `podman`.
```bash
bun run --filter @browseros/build-tools smoke:vm -- --arch arm64 --qcow ./dist/browseros-vm-2026.04.22-arm64.qcow2.zst
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 only one manifest 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 vm --merge-from https://cdn.browseros.com/vm/manifest.json
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
```
Development cache files land under `~/.browseros-dev/cache/vm/`. Production-mode cache files land under `~/.browseros/cache/vm/`.

View File

@@ -1,10 +0,0 @@
{
"vmVersion": "2026.04.22",
"agents": [
{
"name": "openclaw",
"image": "ghcr.io/openclaw/openclaw",
"version": "2026.4.12"
}
]
}

View File

@@ -1,26 +0,0 @@
{
"name": "@browseros/build-tools",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "BrowserOS release artifact producer and dev cache sync",
"scripts": {
"build:disk": "bun run scripts/build-disk.ts",
"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",
"smoke:tarball": "bun run scripts/smoke-tarball.ts",
"smoke:vm": "bun run scripts/smoke-vm.ts",
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.933.0",
"@browseros/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^24.3.3"
}
}

View File

@@ -1 +0,0 @@
ef5acb5908f6ef1f7ffcf3a63913cdf618da3229ffa3b04e3727959e36bb9de1

View File

@@ -1 +0,0 @@
e879ce3547728da306bb0e634ee9f8309b8923b75873bf44cac161853b170f2b

View File

@@ -1,12 +0,0 @@
{
"arm64": {
"upstreamVersion": "20260413-2447",
"url": "https://cloud.debian.org/images/cloud/bookworm/20260413-2447/debian-12-genericcloud-arm64-20260413-2447.qcow2",
"sha512": "15ad6c52e255c84eb0e91001c5907b27199d8a7164d8ac172cfe9c92850dfaf606a6c3161d6af7f0fd5a5fef2aa8dcd9a23c2eb0fedbfcddb38e2bc306cba98f"
},
"x64": {
"upstreamVersion": "20260413-2447",
"url": "https://cloud.debian.org/images/cloud/bookworm/20260413-2447/debian-12-genericcloud-amd64-20260413-2447.qcow2",
"sha512": "db11b13c4efcc37828ffadae521d101e85079d349e1418074087bb7d306f11caccdc2b0b539d6fd50d623d40a898f83c6137268a048d7700397dc35b7dcbc927"
}
}

View File

@@ -1,19 +0,0 @@
# BrowserOS VM recipe — Debian 12 (bookworm) genericcloud
# Consumed by scripts/build-disk.ts. One virt-customize primitive per line.
# Ops: run-command | copy-in <src>:<dest-dir> | upload <src>:<dest-file> | write <path>:<content> | truncate <path>
# {version} and {manifest_tmp} are substituted at build time.
run-command apt-get update
run-command DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends podman crun fuse-overlayfs slirp4netns ca-certificates
run-command systemctl enable podman.socket
copy-in auth.json:/etc/containers/
run-command useradd --create-home --uid 1000 --shell /bin/bash browseros
run-command usermod -aG sudo browseros
copy-in sudoers-browseros:/etc/sudoers.d/
run-command chmod 0440 /etc/sudoers.d/sudoers-browseros
write /etc/browseros-vm-version:{version}
upload {manifest_tmp}:/etc/browseros-vm-manifest.json
run-command apt-get clean
run-command rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
run-command rm -f /etc/ssh/ssh_host_*
truncate /etc/machine-id

View File

@@ -1 +0,0 @@
browseros ALL=(ALL) NOPASSWD: ALL

View File

@@ -1,216 +0,0 @@
#!/usr/bin/env bun
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import {
copyFile,
mkdir,
readFile,
rm,
stat,
writeFile,
} from 'node:fs/promises'
import path from 'node:path'
import { parseArgs } from 'node:util'
import { $ } from 'bun'
import { type Arch, parseArch } from './common/arch'
import { fetchWithTimeout } from './common/fetch'
import { qcow2Key } from './common/manifest'
import { sha256File } from './common/sha256'
type ChunkSink = ReturnType<ReturnType<typeof Bun.file>['writer']>
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
version: { type: 'string' },
arch: { type: 'string' },
'output-dir': { type: 'string', default: './dist' },
},
})
if (!values.version || !values.arch) {
console.error(
'usage: build:disk -- --version <YYYY.MM.DD[-N]> --arch <arm64|x64> [--output-dir ./dist]',
)
process.exit(1)
}
const arch = parseArch(values.arch)
const version = values.version
const outDir = values['output-dir']
const pkgRoot = path.resolve(import.meta.dir, '..')
await mkdir(outDir, { recursive: true })
const baseImages = JSON.parse(
await readFile(path.join(pkgRoot, 'recipe/base-images.json'), 'utf8'),
) as Record<Arch, { upstreamVersion: string; url: string; sha512: string }>
const base = baseImages[arch]
if (!base) throw new Error(`missing base image for arch ${arch}`)
const basePath = path.join(outDir, `base-${arch}.qcow2`)
const workPath = path.join(outDir, `work-${version}-${arch}.qcow2`)
const buildMarkerPath = path.join(outDir, `build-marker-${arch}.json`)
const recipePath = path.join(pkgRoot, 'recipe/browseros-vm.recipe')
const rawOut = path.join(outDir, `browseros-vm-${version}-${arch}.qcow2`)
const zstOut = `${rawOut}.zst`
try {
await download(base.url, basePath)
await verifySha512(basePath, base.sha512)
await copyFile(basePath, workPath)
await writeFile(
buildMarkerPath,
`${JSON.stringify({ name: 'browseros-vm', version, arch, phase: 'build' }, null, 2)}\n`,
)
const recipeText = await readFile(recipePath, 'utf8')
const args = composeVirtCustomizeArgs({
diskPath: workPath,
recipeText,
recipeDir: path.dirname(recipePath),
substitutions: { version, manifest_tmp: buildMarkerPath },
})
await spawnChecked(['virt-customize', ...args])
await $`virt-sparsify --in-place ${workPath}`.quiet()
await $`qemu-img convert -O qcow2 -c ${workPath} ${rawOut}`.quiet()
await $`zstd -19 --long=30 -T0 -f -o ${zstOut} ${rawOut}`.quiet()
const sha = await sha256File(zstOut)
const size = (await stat(zstOut)).size
await writeFile(`${zstOut}.sha256`, `${sha} ${path.basename(zstOut)}\n`)
console.log(
JSON.stringify(
{
key: qcow2Key(version, arch),
path: zstOut,
sha256: sha,
sizeBytes: size,
},
null,
2,
),
)
} finally {
await rm(workPath, { force: true })
await rm(basePath, { force: true })
await rm(rawOut, { force: true })
await rm(buildMarkerPath, { force: true })
}
function composeVirtCustomizeArgs(opts: {
diskPath: string
recipeText: string
recipeDir: string
substitutions: Record<string, string>
}): string[] {
const out = ['-a', opts.diskPath, '--network']
for (const rawLine of opts.recipeText.split('\n')) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const spaceAt = line.indexOf(' ')
if (spaceAt === -1) throw new Error(`invalid recipe line: ${line}`)
const op = line.slice(0, spaceAt)
const rest = subst(line.slice(spaceAt + 1), opts.substitutions)
if (op === 'run-command') {
out.push('--run-command', rest)
continue
}
if (op === 'copy-in') {
const colonAt = rest.indexOf(':')
if (colonAt === -1) throw new Error(`invalid copy-in line: ${line}`)
const source = rest.slice(0, colonAt)
const target = rest.slice(colonAt + 1)
out.push('--copy-in', `${path.resolve(opts.recipeDir, source)}:${target}`)
continue
}
if (op === 'upload') {
const colonAt = rest.indexOf(':')
if (colonAt === -1) throw new Error(`invalid upload line: ${line}`)
const source = rest.slice(0, colonAt)
const target = rest.slice(colonAt + 1)
out.push('--upload', `${path.resolve(opts.recipeDir, source)}:${target}`)
continue
}
if (op === 'write') {
out.push('--write', rest)
continue
}
if (op === 'truncate') {
out.push('--truncate', rest)
continue
}
throw new Error(`unknown recipe op: ${op}`)
}
return out
}
function subst(value: string, vars: Record<string, string>): string {
return value.replace(/\{(\w+)\}/g, (_match, key: string) => {
const replacement = vars[key]
if (!replacement) throw new Error(`no substitution for {${key}}`)
return replacement
})
}
async function download(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 verifySha512(filePath: string, expected: string): Promise<void> {
const hash = createHash('sha512')
for await (const chunk of createReadStream(filePath)) {
hash.update(chunk)
}
const actual = hash.digest('hex')
if (actual !== expected) {
throw new Error(
`sha512 mismatch for ${filePath}: expected ${expected}, got ${actual}`,
)
}
}
async function spawnChecked(argv: string[]): Promise<void> {
const proc = Bun.spawn(argv, {
stdout: 'inherit',
stderr: 'inherit',
env: {
...process.env,
LIBGUESTFS_BACKEND: process.env.LIBGUESTFS_BACKEND ?? 'direct',
},
})
const code = await proc.exited
if (code !== 0) throw new Error(`${argv[0]} exited ${code}`)
}
async function pumpStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
sink: ChunkSink,
): Promise<void> {
for (;;) {
const { done, value } = await reader.read()
if (done) break
sink.write(value)
}
}

View File

@@ -1,92 +0,0 @@
#!/usr/bin/env bun
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { parseArgs } from 'node:util'
import { parseArch, podmanArch } from './common/arch'
import { type Bundle, tarballKey } from './common/manifest'
import { sha256File } from './common/sha256'
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
agent: { type: 'string' },
arch: { type: 'string' },
'output-dir': { type: 'string', default: './dist/images' },
},
})
if (!values.agent || !values.arch) {
console.error(
'usage: build:tarball -- --agent <name> --arch <arm64|x64> [--output-dir ./dist/images]',
)
process.exit(1)
}
const arch = parseArch(values.arch)
const outDir = values['output-dir']
await mkdir(outDir, { recursive: true })
const pkgRoot = path.resolve(import.meta.dir, '..')
const bundle = JSON.parse(
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
) as Bundle
const agent = bundle.agents.find(({ name }) => name === values.agent)
if (!agent) throw new Error(`unknown agent: ${values.agent}`)
const ref = `${agent.image}:${agent.version}`
const tarballPath = path.join(
outDir,
path.basename(tarballKey(agent.name, agent.version, arch)),
)
const tarPath = tarballPath.slice(0, -'.gz'.length)
await rm(tarballPath, { force: true })
await rm(`${tarballPath}.sha256`, { force: true })
await rm(tarPath, { force: true })
await spawnChecked([
'podman',
'pull',
'--os',
'linux',
'--arch',
podmanArch(arch),
ref,
])
await spawnChecked([
'podman',
'save',
'--format=oci-archive',
'--output',
tarPath,
ref,
])
await spawnChecked(['gzip', '-9', '-f', tarPath])
const sha = await sha256File(tarballPath)
const size = (await stat(tarballPath)).size
await writeFile(
`${tarballPath}.sha256`,
`${sha} ${path.basename(tarballPath)}\n`,
)
console.log(
JSON.stringify(
{
key: tarballKey(agent.name, agent.version, arch),
path: tarballPath,
sha256: sha,
sizeBytes: size,
},
null,
2,
),
)
async function spawnChecked(argv: string[]): Promise<void> {
const proc = Bun.spawn(argv, {
stdout: 'inherit',
stderr: 'inherit',
})
const code = await proc.exited
if (code !== 0) throw new Error(`${argv[0]} exited ${code}`)
}

View File

@@ -1,164 +0,0 @@
#!/usr/bin/env bun
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { homedir, arch as hostArch } from 'node:os'
import path from 'node:path'
import { parseArgs } from 'node:util'
import { PATHS } from '@browseros/shared/constants/paths'
import { ARCHES, type Arch } from './common/arch'
import { fetchWithTimeout } from './common/fetch'
import type { Artifact, VmManifest } 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: VmManifest | null
remote: VmManifest
cacheRoot: string
arches: Arch[]
}): PlanItem[] {
const out: PlanItem[] = []
for (const arch of opts.arches) {
maybeAdd(
out,
opts.remote.vmDisk[arch],
opts.local?.vmDisk[arch],
opts.cacheRoot,
)
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 VmManifest
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(`cache up to date at vmVersion ${remote.vmVersion}`)
process.exit(0)
}
console.log(
`syncing ${plan.length} artifact(s) for vmVersion ${remote.vmVersion}`,
)
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<VmManifest | null> {
try {
return JSON.parse(await readFile(manifestPath, 'utf8')) as VmManifest
} 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)
}
}

View File

@@ -1,12 +0,0 @@
export type Arch = 'arm64' | 'x64'
export const ARCHES: readonly Arch[] = ['arm64', 'x64']
export function parseArch(raw: string): Arch {
if (raw === 'arm64' || raw === 'x64') return raw
throw new Error(`unknown arch: ${raw} (expected arm64|x64)`)
}
export function podmanArch(arch: Arch): 'arm64' | 'amd64' {
return arch === 'x64' ? 'amd64' : 'arm64'
}

View File

@@ -1,22 +0,0 @@
export async function fetchWithTimeout(
url: string,
init: RequestInit = {},
timeoutMs = 30_000,
): Promise<Response> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
return await fetch(url, {
...init,
signal: init.signal ?? controller.signal,
})
} catch (error) {
if ((error as { name?: string }).name === 'AbortError') {
throw new Error(`fetch timed out after ${timeoutMs}ms: ${url}`)
}
throw error
} finally {
clearTimeout(timer)
}
}

View File

@@ -1,96 +0,0 @@
import { ARCHES, type Arch } from './arch'
export interface Artifact {
key: string
sha256: string
sizeBytes: number
}
export interface AgentEntry {
image: string
version: string
tarballs: Record<Arch, Artifact>
}
export interface VmManifest {
schemaVersion: 1
vmVersion: string
updatedAt: string
vmDisk: Record<Arch, Artifact>
agents: Record<string, AgentEntry>
}
export interface BundleAgent {
name: string
image: string
version: string
}
export interface Bundle {
vmVersion: string
agents: BundleAgent[]
}
export interface ArtifactInput {
sha256: string
sizeBytes: number
}
export interface ArtifactInputs {
vmDisk: Record<Arch, ArtifactInput>
agents: Record<string, Record<Arch, ArtifactInput>>
}
export function qcow2Key(vmVersion: string, arch: Arch): string {
return `vm/browseros-vm-${vmVersion}-${arch}.qcow2.zst`
}
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(),
): VmManifest {
const vmDisk = {} as Record<Arch, Artifact>
for (const arch of ARCHES) {
const entry = inputs.vmDisk[arch]
if (!entry) throw new Error(`missing vmDisk inputs for arch ${arch}`)
vmDisk[arch] = {
key: qcow2Key(bundle.vmVersion, arch),
sha256: entry.sha256,
sizeBytes: entry.sizeBytes,
}
}
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: 1,
vmVersion: bundle.vmVersion,
updatedAt: now.toISOString(),
vmDisk,
agents,
}
}

View File

@@ -1,96 +0,0 @@
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import {
GetObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3'
function required(name: string): string {
const value = process.env[name]?.trim()
if (!value) throw new Error(`missing env var: ${name}`)
return value
}
export function createR2Client(): S3Client {
return new S3Client({
region: 'auto',
endpoint: `https://${required('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: required('R2_ACCESS_KEY_ID'),
secretAccessKey: required('R2_SECRET_ACCESS_KEY'),
},
})
}
export function getBucket(): string {
return required('R2_BUCKET')
}
export function getCdnBase(): string {
return process.env.R2_PUBLIC_BASE_URL?.trim() ?? 'https://cdn.browseros.com'
}
export async function putFile(
client: S3Client,
bucket: string,
key: string,
filePath: string,
contentType: string,
): Promise<void> {
const { size } = await stat(filePath)
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: createReadStream(filePath),
ContentLength: size,
ContentType: contentType,
}),
)
}
export async function putBody(
client: S3Client,
bucket: string,
key: string,
body: string,
contentType: string,
): Promise<void> {
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentLength: Buffer.byteLength(body),
ContentType: contentType,
}),
)
}
export async function getBody(
client: S3Client,
bucket: string,
key: string,
): Promise<string | null> {
try {
const response = await client.send(
new GetObjectCommand({ Bucket: bucket, Key: key }),
)
const body = response.Body as
| { transformToByteArray(): Promise<Uint8Array> }
| undefined
if (!body) throw new Error(`missing response body for R2 key: ${key}`)
const bytes = await body.transformToByteArray()
return new TextDecoder().decode(bytes)
} catch (error) {
const cause = error as {
name?: string
$metadata?: { httpStatusCode?: number }
}
if (cause.name === 'NoSuchKey' || cause.$metadata?.httpStatusCode === 404) {
return null
}
throw error
}
}

View File

@@ -1,22 +0,0 @@
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
export async function sha256File(path: string): Promise<string> {
const hash = createHash('sha256')
for await (const chunk of createReadStream(path)) {
hash.update(chunk)
}
return hash.digest('hex')
}
export async function verifySha256(
path: string,
expected: string,
): Promise<void> {
const actual = await sha256File(path)
if (actual !== expected) {
throw new Error(
`sha256 mismatch for ${path}: expected ${expected}, got ${actual}`,
)
}
}

View File

@@ -1,29 +0,0 @@
#!/usr/bin/env bun
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { parseArgs } from 'node:util'
import { createR2Client, getBody, getBucket } from './common/r2'
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
key: { type: 'string' },
out: { type: 'string' },
},
})
if (!values.key || !values.out) {
console.error('usage: download -- --key <r2-key> --out <path>')
process.exit(1)
}
const body = await getBody(createR2Client(), getBucket(), values.key)
if (body === null) {
throw new Error(
`R2 key not found: ${values.key}. Publish a full manifest before publishing slices.`,
)
}
await mkdir(path.dirname(values.out), { recursive: true })
await writeFile(values.out, body)
console.log(`downloaded ${values.key} to ${values.out}`)

View File

@@ -1,175 +0,0 @@
#!/usr/bin/env bun
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { parseArgs } from 'node:util'
import { ARCHES, type Arch } from './common/arch'
import { fetchWithTimeout } from './common/fetch'
import {
type AgentEntry,
type Artifact,
type ArtifactInputs,
type Bundle,
type BundleAgent,
buildManifest,
qcow2Key,
tarballKey,
type VmManifest,
} 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
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: VmManifest | null
}): Promise<VmManifest> {
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 === 'vm') {
return {
...baseline,
schemaVersion: 1,
vmVersion: opts.bundle.vmVersion,
updatedAt,
vmDisk: await readVmDisk(opts.bundle.vmVersion, opts.distDir),
}
}
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,
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 {
vmDisk: await readArtifactInputs((arch) =>
path.join(distDir, path.basename(qcow2Key(bundle.vmVersion, arch))),
),
agents,
}
}
async function readVmDisk(
vmVersion: string,
distDir: string,
): Promise<Record<Arch, Artifact>> {
const vmDisk = {} as Record<Arch, Artifact>
for (const arch of ARCHES) {
const key = qcow2Key(vmVersion, arch)
const artifactPath = path.join(distDir, path.basename(key))
vmDisk[arch] = { key, ...(await readArtifactInput(artifactPath)) }
}
return vmDisk
}
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 readArtifactInputs(
pathForArch: (arch: Arch) => string,
): Promise<Record<Arch, { sha256: string; sizeBytes: number }>> {
const out = {} as Record<Arch, { sha256: string; sizeBytes: number }>
for (const arch of ARCHES) {
out[arch] = await readArtifactInput(pathForArch(arch))
}
return out
}
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<VmManifest> {
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 VmManifest
}
return JSON.parse(await readFile(src, 'utf8')) as VmManifest
}

View File

@@ -1,108 +0,0 @@
#!/usr/bin/env bun
import { createReadStream, createWriteStream } from 'node:fs'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { pipeline } from 'node:stream/promises'
import { parseArgs } from 'node:util'
import { createGunzip } from 'node:zlib'
import { parseArch, podmanArch } from './common/arch'
import type { Bundle } from './common/manifest'
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
agent: { type: 'string' },
arch: { type: 'string' },
tarball: { type: 'string' },
},
})
if (!values.agent || !values.arch || !values.tarball) {
console.error(
'usage: smoke:tarball -- --agent <name> --arch <arm64|x64> --tarball <path.tar.gz>',
)
process.exit(1)
}
const arch = parseArch(values.arch)
const pkgRoot = path.resolve(import.meta.dir, '..')
const bundle = JSON.parse(
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
) as Bundle
const agent = bundle.agents.find(({ name }) => name === values.agent)
if (!agent) throw new Error(`unknown agent: ${values.agent}`)
const ref = `${agent.image}:${agent.version}`
const tarball = await maybeDecompress(values.tarball)
try {
await spawnChecked(['podman', 'rmi', '-f', ref]).catch(() => {})
await spawnChecked(['podman', 'load', '--input', tarball.path])
const inspected = await inspectImage(ref)
if (inspected.Os !== 'linux') {
throw new Error(`expected linux image, got ${inspected.Os ?? '<missing>'}`)
}
if (inspected.Architecture !== podmanArch(arch)) {
throw new Error(
`expected ${podmanArch(arch)} image, got ${inspected.Architecture ?? '<missing>'}`,
)
}
} finally {
await spawnChecked(['podman', 'rmi', '-f', ref]).catch(() => {})
if (tarball.cleanupDir) {
await rm(tarball.cleanupDir, { recursive: true, force: true })
}
}
console.log('tarball smoke test passed')
async function maybeDecompress(
tarballPath: string,
): Promise<{ path: string; cleanupDir?: string }> {
if (!tarballPath.endsWith('.gz')) return { path: tarballPath }
const cleanupDir = await mkdtemp(path.join(tmpdir(), 'browseros-tar-smoke-'))
const tarPath = path.join(cleanupDir, 'image.tar')
await pipeline(
createReadStream(tarballPath),
createGunzip(),
createWriteStream(tarPath),
)
return { path: tarPath, cleanupDir }
}
async function inspectImage(ref: string): Promise<{
Architecture?: string
Os?: string
}> {
const stdout = await spawnCapture([
'podman',
'inspect',
'--type',
'image',
'--format',
'{{json .}}',
ref,
])
return JSON.parse(stdout) as { Architecture?: string; Os?: string }
}
async function spawnCapture(argv: string[]): Promise<string> {
const proc = Bun.spawn(argv, { stdout: 'pipe', stderr: 'pipe' })
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
if (code !== 0) {
throw new Error(
`${argv[0]} exited ${code}\n${stderr.trim() || stdout.trim()}`,
)
}
return stdout.trim()
}
async function spawnChecked(argv: string[]): Promise<void> {
await spawnCapture(argv)
}

View File

@@ -1,105 +0,0 @@
#!/usr/bin/env bun
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { parseArgs } from 'node:util'
import { $ } from 'bun'
import { type Arch, parseArch } from './common/arch'
const INSTANCE_NAME = 'browseros-vm-smoke'
const SOCKET_POLL_INTERVAL_MS = 2000
const SOCKET_POLL_TIMEOUT_MS = 120_000
type BunRequestInit = RequestInit & { unix?: string }
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
qcow: { type: 'string' },
arch: { type: 'string', default: 'x64' },
limactl: { type: 'string', default: 'limactl' },
},
})
if (!values.qcow) {
console.error(
'usage: smoke:vm -- --qcow <path.qcow2.zst> [--arch arm64|x64] [--limactl limactl]',
)
process.exit(1)
}
const arch = parseArch(values.arch ?? 'x64')
await bootAndProbe(values.qcow, arch, values.limactl ?? 'limactl')
console.log('vm smoke test passed')
async function bootAndProbe(
qcowZstPath: string,
arch: Arch,
limactl: string,
): Promise<void> {
const workDir = await mkdtemp(path.join(tmpdir(), 'browseros-vm-smoke-'))
const qcowPath = path.join(workDir, 'disk.qcow2')
const configPath = path.join(workDir, 'lima.yaml')
const sockPath = path.join(workDir, 'podman.sock')
try {
await $`zstd -d -f -o ${qcowPath} ${qcowZstPath}`.quiet()
await writeFile(configPath, composeLimaConfig(qcowPath, arch, sockPath))
await $`${limactl} start --name=${INSTANCE_NAME} --tty=false ${configPath}`
await waitForSocket(sockPath)
await probePodmanSocket(sockPath)
} finally {
await $`${limactl} stop --force ${INSTANCE_NAME}`.quiet().nothrow()
await $`${limactl} delete --force ${INSTANCE_NAME}`.quiet().nothrow()
await rm(workDir, { recursive: true, force: true })
}
}
function composeLimaConfig(
qcowPath: string,
arch: Arch,
sockPath: string,
): string {
return `vmType: qemu
images:
- location: ${qcowPath}
arch: ${limaArch(arch)}
containerd:
system: false
user: false
mounts: []
provision: []
portForwards:
- guestSocket: /run/podman/podman.sock
hostSocket: ${sockPath}
proto: unix
`
}
function limaArch(arch: Arch): 'aarch64' | 'x86_64' {
return arch === 'arm64' ? 'aarch64' : 'x86_64'
}
async function waitForSocket(sockPath: string): Promise<void> {
const deadline = Date.now() + SOCKET_POLL_TIMEOUT_MS
while (Date.now() < deadline) {
if (await Bun.file(sockPath).exists()) return
await Bun.sleep(SOCKET_POLL_INTERVAL_MS)
}
throw new Error(
`podman socket did not appear within ${SOCKET_POLL_TIMEOUT_MS}ms: ${sockPath}`,
)
}
async function probePodmanSocket(sockPath: string): Promise<void> {
const init: BunRequestInit = { unix: sockPath }
const response = await fetch('http://d/v4.0.0/libpod/_ping', init)
if (!response.ok) {
throw new Error(`podman ping failed: ${response.status}`)
}
const body = (await response.text()).trim()
if (body !== 'OK') {
throw new Error(`podman ping body unexpected: ${body}`)
}
}

View File

@@ -1,42 +0,0 @@
#!/usr/bin/env bun
import { parseArgs } from 'node:util'
import { createR2Client, getBucket, putBody, putFile } from './common/r2'
import { sha256File } from './common/sha256'
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
file: { type: 'string' },
key: { type: 'string' },
'content-type': { type: 'string' },
'sidecar-sha': { type: 'boolean' },
},
})
if (!values.file || !values.key) {
throw new Error('--file and --key required')
}
const contentType = values['content-type'] ?? 'application/octet-stream'
const client = createR2Client()
const bucket = getBucket()
try {
await putFile(client, bucket, values.key, values.file, contentType)
console.log(`uploaded ${values.file} to ${bucket}/${values.key}`)
if (values['sidecar-sha']) {
const sha = await sha256File(values.file)
const filename = values.file.split('/').pop() ?? values.file
await putBody(
client,
bucket,
`${values.key}.sha256`,
`${sha} ${filename}\n`,
'text/plain; charset=utf-8',
)
console.log(`uploaded sha256 to ${bucket}/${values.key}.sha256`)
}
} finally {
client.destroy()
}

View File

@@ -1,18 +0,0 @@
import { describe, expect, it } from 'bun:test'
import { parseArch, podmanArch } from '../scripts/common/arch'
describe('arch helpers', () => {
it('normalizes BrowserOS arches for podman', () => {
expect(podmanArch('arm64')).toBe('arm64')
expect(podmanArch('x64')).toBe('amd64')
})
it('parses supported release arches', () => {
expect(parseArch('arm64')).toBe('arm64')
expect(parseArch('x64')).toBe('x64')
})
it('rejects unsupported release arches', () => {
expect(() => parseArch('amd64')).toThrow('unknown arch: amd64')
})
})

View File

@@ -1,282 +0,0 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import {
type PlanItem,
planSync,
readLocalManifest,
selectSyncArches,
} from '../scripts/cache-sync'
import type { VmManifest } from '../scripts/common/manifest'
import { sha256File } from '../scripts/common/sha256'
const openclaw = {
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
}
function manifest(
vmVersion: string,
diskSha: string,
tarSha: string,
): VmManifest {
return {
schemaVersion: 1,
vmVersion,
updatedAt: '2026-04-22T00:00:00.000Z',
vmDisk: {
arm64: {
key: `vm/browseros-vm-${vmVersion}-arm64.qcow2.zst`,
sha256: `${diskSha}-arm64`,
sizeBytes: 101,
},
x64: {
key: `vm/browseros-vm-${vmVersion}-x64.qcow2.zst`,
sha256: `${diskSha}-x64`,
sizeBytes: 102,
},
},
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,
},
},
},
},
}
}
function keys(plan: PlanItem[]): string[] {
return plan.map((item) => item.key)
}
describe('planSync', () => {
it('downloads every selected-arch artifact for a fresh cache', () => {
const remote = manifest('2026.04.22', 'd1', 't1')
expect(
keys(planSync({ local: null, remote, cacheRoot: '/c', arches: ['x64'] })),
).toEqual([
'vm/browseros-vm-2026.04.22-x64.qcow2.zst',
'vm/images/openclaw-2026.4.12-x64.tar.gz',
])
})
it('does nothing when the local manifest matches the remote manifest', () => {
const remote = manifest('2026.04.22', 'd1', 't1')
expect(
planSync({ local: remote, remote, cacheRoot: '/c', arches: ['x64'] }),
).toEqual([])
})
it('downloads only artifacts whose sha256 changed', () => {
const local = manifest('2026.04.20', 'd-old', 't1')
const remote = manifest('2026.04.22', 'd-new', 't1')
expect(
keys(planSync({ local, remote, cacheRoot: '/c', arches: ['x64'] })),
).toEqual(['vm/browseros-vm-2026.04.22-x64.qcow2.zst'])
})
it('supports syncing all release arches', () => {
const remote = manifest('2026.04.22', 'd1', 't1')
expect(
planSync({
local: null,
remote,
cacheRoot: '/c',
arches: ['arm64', 'x64'],
}),
).toHaveLength(4)
})
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('merges a vm slice while preserving agents from the baseline', async () => {
dir = await mkdtemp(path.join(tmpdir(), 'browseros-emit-vm-'))
const distDir = path.join(dir, 'dist')
await writeVmFiles(distDir)
const baseline = manifest('2026.04.20', 'old-disk', 'old-tar')
const baselinePath = path.join(dir, 'baseline.json')
const outPath = path.join(dir, 'manifest.json')
await writeJson(baselinePath, baseline)
await runEmitManifest([
'--slice',
'vm',
'--dist-dir',
distDir,
'--merge-from',
baselinePath,
'--out',
outPath,
])
const merged = JSON.parse(await readFile(outPath, 'utf8')) as VmManifest
expect(merged.vmVersion).toBe('2026.04.22')
expect(merged.agents).toEqual(baseline.agents)
expect(merged.vmDisk.x64.sha256).toBe(
await sha256File(
path.join(distDir, 'browseros-vm-2026.04.22-x64.qcow2.zst'),
),
)
})
it('merges an agent slice while preserving vmDisk 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('2026.04.20', 'old-disk', 'old-tar')
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 VmManifest
expect(merged.vmVersion).toBe('2026.04.20')
expect(merged.vmDisk).toEqual(baseline.vmDisk)
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 distDir = path.join(dir, 'dist')
await writeVmFiles(distDir)
const result = await runEmitManifest(
[
'--slice',
'vm',
'--dist-dir',
distDir,
'--out',
path.join(dir, 'out.json'),
],
false,
)
expect(result.code).toBe(1)
expect(result.stderr).toContain('--slice vm requires --merge-from')
})
})
async function writeVmFiles(distDir: string): Promise<void> {
await mkdir(distDir, { recursive: true })
await writeFile(
path.join(distDir, 'browseros-vm-2026.04.22-arm64.qcow2.zst'),
'arm disk',
)
await writeFile(
path.join(distDir, 'browseros-vm-2026.04.22-x64.qcow2.zst'),
'x64 disk',
)
}
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 }
}

View File

@@ -1,123 +0,0 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
type ArtifactInputs,
type Bundle,
buildManifest,
qcow2Key,
tarballKey,
} from '../scripts/common/manifest'
import { verifySha256 } from '../scripts/common/sha256'
const bundle: Bundle = {
vmVersion: '2026.04.22',
agents: [
{
name: 'openclaw',
image: 'ghcr.io/openclaw/openclaw',
version: '2026.4.12',
},
],
}
const inputs: ArtifactInputs = {
vmDisk: {
arm64: { sha256: 'disk-arm', sizeBytes: 11 },
x64: { sha256: 'disk-x64', sizeBytes: 12 },
},
agents: {
openclaw: {
arm64: { sha256: 'tar-arm', sizeBytes: 21 },
x64: { sha256: 'tar-x64', sizeBytes: 22 },
},
},
}
describe('manifest helpers', () => {
it('builds release artifact keys', () => {
expect(qcow2Key('2026.04.22', 'arm64')).toBe(
'vm/browseros-vm-2026.04.22-arm64.qcow2.zst',
)
expect(tarballKey('openclaw', '2026.4.12', 'x64')).toBe(
'vm/images/openclaw-2026.4.12-x64.tar.gz',
)
})
it('builds a manifest from bundle metadata and artifact inputs', () => {
const manifest = buildManifest(
bundle,
inputs,
new Date('2026-04-22T00:00:00.000Z'),
)
expect(manifest).toMatchObject({
schemaVersion: 1,
vmVersion: '2026.04.22',
updatedAt: '2026-04-22T00:00:00.000Z',
vmDisk: {
arm64: {
key: 'vm/browseros-vm-2026.04.22-arm64.qcow2.zst',
sha256: 'disk-arm',
sizeBytes: 11,
},
},
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 artifact inputs are missing', () => {
expect(() =>
buildManifest(bundle, {
vmDisk: { arm64: inputs.vmDisk.arm64 } as ArtifactInputs['vmDisk'],
agents: inputs.agents,
}),
).toThrow('missing vmDisk inputs for arch x64')
expect(() =>
buildManifest(bundle, {
vmDisk: inputs.vmDisk,
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',
)
})
})

View File

@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"resolveJsonModule": true
},
"include": ["scripts/**/*", "tests/**/*", "package.json", "bundle.json"]
}

View File

@@ -57,6 +57,10 @@
"types": "./src/types/role-aware-agents.ts",
"default": "./src/types/role-aware-agents.ts"
},
"./types/role-programs": {
"types": "./src/types/role-programs.ts",
"default": "./src/types/role-programs.ts"
},
"./schemas/llm": {
"types": "./src/schemas/llm.ts",
"default": "./src/schemas/llm.ts"

View File

@@ -1,4 +1,4 @@
export const OPENCLAW_GATEWAY_CONTAINER_PORT = 18789
export const OPENCLAW_GATEWAY_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`

View File

@@ -9,8 +9,6 @@
export const PATHS = {
DEFAULT_EXECUTION_DIR: process.cwd(),
BROWSEROS_DIR_NAME: '.browseros',
DEV_BROWSEROS_DIR_NAME: '.browseros-dev',
CACHE_DIR_NAME: 'cache',
MEMORY_DIR_NAME: 'memory',
SESSIONS_DIR_NAME: 'sessions',
TOOL_OUTPUT_DIR_NAME: 'tool-output',

View File

@@ -0,0 +1,75 @@
export type BrowserOSProgramSchedule =
| {
type: 'daily'
time: string
daysOfWeek?: Array<0 | 1 | 2 | 3 | 4 | 5 | 6>
}
| {
type: 'hourly'
interval: number
}
| {
type: 'minutes'
interval: number
}
| {
type: 'manual'
}
export interface BrowserOSStandingOrder {
id: string
title: string
instruction: string
enabled: boolean
}
export interface BrowserOSAgentProgram {
id: string
agentId: string
agentName: string
roleId?: string
name: string
description: string
prompt: string
schedule: BrowserOSProgramSchedule
enabled: boolean
standingOrders: BrowserOSStandingOrder[]
createdAt: string
updatedAt: string
lastRunAt?: string
nextRunAt?: string
}
export interface BrowserOSProgramRun {
id: string
programId: string
agentId: string
startedAt: string
completedAt?: string
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
trigger: 'manual' | 'schedule' | 'retry'
summary?: string
finalResult?: string
error?: string
sessionKey?: string
}
export interface CreateAgentProgramInput {
name: string
description: string
prompt: string
schedule: BrowserOSProgramSchedule
enabled?: boolean
standingOrders?: BrowserOSStandingOrder[]
}
export interface UpdateAgentProgramInput {
name?: string
description?: string
prompt?: string
schedule?: BrowserOSProgramSchedule
enabled?: boolean
standingOrders?: BrowserOSStandingOrder[]
lastRunAt?: string
nextRunAt?: string
}

View File

@@ -1,26 +1,134 @@
{
"resources": [
{
"name": "Lima limactl - macOS ARM64",
"name": "Podman CLI - macOS ARM64",
"source": {
"type": "r2",
"key": "third_party/lima/limactl-darwin-arm64"
"key": "third_party/podman/podman-darwin-arm64"
},
"destination": "resources/bin/third_party/lima/limactl",
"destination": "resources/bin/third_party/podman/podman",
"os": ["macos"],
"arch": ["arm64"],
"executable": true
},
{
"name": "Lima limactl - macOS x64",
"name": "Podman gvproxy - macOS ARM64",
"source": {
"type": "r2",
"key": "third_party/lima/limactl-darwin-x64"
"key": "third_party/podman/gvproxy-darwin-arm64"
},
"destination": "resources/bin/third_party/lima/limactl",
"destination": "resources/bin/third_party/podman/gvproxy",
"os": ["macos"],
"arch": ["arm64"],
"executable": true
},
{
"name": "Podman vfkit - macOS ARM64",
"source": {
"type": "r2",
"key": "third_party/podman/vfkit-darwin-arm64"
},
"destination": "resources/bin/third_party/podman/vfkit",
"os": ["macos"],
"arch": ["arm64"],
"executable": true
},
{
"name": "Podman krunkit - macOS ARM64",
"source": {
"type": "r2",
"key": "third_party/podman/krunkit-darwin-arm64"
},
"destination": "resources/bin/third_party/podman/krunkit",
"os": ["macos"],
"arch": ["arm64"],
"executable": true
},
{
"name": "Podman mac helper - macOS ARM64",
"source": {
"type": "r2",
"key": "third_party/podman/podman-mac-helper-darwin-arm64"
},
"destination": "resources/bin/third_party/podman/podman-mac-helper",
"os": ["macos"],
"arch": ["arm64"],
"executable": true
},
{
"name": "Podman CLI - macOS x64",
"notes": "krunkit is intentionally omitted on macOS x64 because the official amd64 Podman installer ships an arm64-only krunkit helper",
"source": {
"type": "r2",
"key": "third_party/podman/podman-darwin-x64"
},
"destination": "resources/bin/third_party/podman/podman",
"os": ["macos"],
"arch": ["x64"],
"executable": true
},
{
"name": "Podman gvproxy - macOS x64",
"source": {
"type": "r2",
"key": "third_party/podman/gvproxy-darwin-x64"
},
"destination": "resources/bin/third_party/podman/gvproxy",
"os": ["macos"],
"arch": ["x64"],
"executable": true
},
{
"name": "Podman vfkit - macOS x64",
"source": {
"type": "r2",
"key": "third_party/podman/vfkit-darwin-x64"
},
"destination": "resources/bin/third_party/podman/vfkit",
"os": ["macos"],
"arch": ["x64"],
"executable": true
},
{
"name": "Podman mac helper - macOS x64",
"source": {
"type": "r2",
"key": "third_party/podman/podman-mac-helper-darwin-x64"
},
"destination": "resources/bin/third_party/podman/podman-mac-helper",
"os": ["macos"],
"arch": ["x64"],
"executable": true
},
{
"name": "Podman CLI - Windows x64",
"source": {
"type": "r2",
"key": "third_party/podman/podman-windows-x64.exe"
},
"destination": "resources/bin/third_party/podman/podman.exe",
"os": ["windows"],
"arch": ["x64"]
},
{
"name": "Podman gvproxy - Windows x64",
"source": {
"type": "r2",
"key": "third_party/podman/gvproxy-windows-x64.exe"
},
"destination": "resources/bin/third_party/podman/gvproxy.exe",
"os": ["windows"],
"arch": ["x64"]
},
{
"name": "Podman win-sshproxy - Windows x64",
"source": {
"type": "r2",
"key": "third_party/podman/win-sshproxy-windows-x64.exe"
},
"destination": "resources/bin/third_party/podman/win-sshproxy.exe",
"os": ["windows"],
"arch": ["x64"]
}
]
}

View File

@@ -44,10 +44,6 @@ app.add_typer(release.app, name="release", help="Release automation")
from .cli import ota
app.add_typer(ota.app, name="ota", help="OTA update automation")
# Third-party resource uploads (Lima, future VM disk + agent tarballs)
from .cli import storage
app.add_typer(storage.app, name="upload", help="Upload third-party resources to R2")
if __name__ == "__main__":
app()

View File

@@ -1,269 +0,0 @@
#!/usr/bin/env python3
"""Storage CLI - Push third-party resources to R2 for build:server ingestion."""
import hashlib
import json
import os
import tarfile
import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import requests
import typer
from ..common.env import EnvConfig
from ..common.utils import log_error, log_info, log_success, log_warning
from ..modules.storage.r2 import (
BOTO3_AVAILABLE,
get_r2_client,
upload_file_to_r2,
)
LIMA_RELEASE_BASE = "https://github.com/lima-vm/lima/releases/download"
LIMA_R2_PREFIX = "third_party/lima"
LIMA_MANIFEST_KEY = f"{LIMA_R2_PREFIX}/manifest.json"
LIMA_HTTP_TIMEOUT_S = 60
@dataclass(frozen=True)
class LimaArch:
"""Arch-pair: the suffix Lima uses upstream and the suffix we use in R2."""
internal: str # "arm64" | "x64" — how our R2 keys name it
upstream: str # "Darwin-arm64" | "Darwin-x86_64" — Lima's tarball suffix
LIMA_ARCHES: Tuple[LimaArch, ...] = (
LimaArch(internal="arm64", upstream="Darwin-arm64"),
LimaArch(internal="x64", upstream="Darwin-x86_64"),
)
app = typer.Typer(
help="Upload third-party resources to Cloudflare R2",
pretty_exceptions_enable=False,
pretty_exceptions_show_locals=False,
)
@app.command("lima")
def upload_lima(
version: str = typer.Option(
...,
"--version",
"-v",
help="Lima release tag, e.g. v1.2.3",
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help="Download + verify only; skip R2 uploads.",
),
) -> None:
"""Download limactl from a Lima GitHub release and push to R2."""
if not BOTO3_AVAILABLE:
log_error("boto3 not installed — run: pip install boto3")
raise typer.Exit(1)
env = EnvConfig()
if not env.has_r2_config():
log_error(
"R2 configuration missing. Required: "
"R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY"
)
raise typer.Exit(1)
tag = _normalize_version_tag(version)
client = None if dry_run else get_r2_client(env)
if not dry_run and client is None:
log_error("Failed to create R2 client")
raise typer.Exit(1)
with tempfile.TemporaryDirectory(prefix="lima-upload-") as tmp:
tmp_dir = Path(tmp)
checksums = _fetch_checksums(tag, tmp_dir)
uploaded_keys: List[str] = []
object_shas: Dict[str, str] = {}
tarball_shas: Dict[str, str] = {}
try:
for arch in LIMA_ARCHES:
tarball_sha, object_sha, r2_key = _process_arch(
tag, arch, tmp_dir, checksums, client, env, dry_run
)
tarball_shas[arch.internal] = tarball_sha
object_shas[arch.internal] = object_sha
if not dry_run:
uploaded_keys.append(r2_key)
manifest = _build_manifest(tag, tarball_shas, object_shas)
_upload_manifest(client, env, manifest, tmp_dir, dry_run)
# Any failure mid-loop (download, sha verify, extract, upload) must
# roll back prior arch uploads so R2 never holds a mixed-version pair.
except Exception as exc:
if not dry_run and uploaded_keys:
log_warning(f"Upload failed — rolling back {len(uploaded_keys)} object(s)")
_rollback(client, env.r2_bucket, uploaded_keys)
log_error(f"Lima upload aborted: {exc}")
raise typer.Exit(1)
log_success(f"Lima {tag} uploaded for {[a.internal for a in LIMA_ARCHES]}")
def _normalize_version_tag(version: str) -> str:
return version if version.startswith("v") else f"v{version}"
def _fetch_checksums(tag: str, tmp_dir: Path) -> Dict[str, str]:
url = f"{LIMA_RELEASE_BASE}/{tag}/SHA256SUMS"
dest = tmp_dir / "SHA256SUMS"
log_info(f"Fetching {url}")
_download(url, dest)
return _parse_checksums(dest.read_text(encoding="utf-8"))
def _parse_checksums(contents: str) -> Dict[str, str]:
"""Parse lines like '<sha256> lima-1.2.3-Darwin-arm64.tar.gz'."""
entries: Dict[str, str] = {}
for raw_line in contents.splitlines():
line = raw_line.strip()
if not line:
continue
parts = line.split(None, 1)
if len(parts) != 2:
raise RuntimeError(f"Malformed SHA256SUMS line: {raw_line!r}")
sha, name = parts[0].lower(), parts[1].lstrip("*").strip()
if len(sha) != 64 or not all(c in "0123456789abcdef" for c in sha):
raise RuntimeError(f"Invalid sha256 in SHA256SUMS: {raw_line!r}")
entries[name] = sha
return entries
def _process_arch(
tag: str,
arch: LimaArch,
tmp_dir: Path,
checksums: Dict[str, str],
client: Any,
env: EnvConfig,
dry_run: bool,
) -> Tuple[str, str, str]:
version_num = tag.lstrip("v")
tarball_name = f"lima-{version_num}-{arch.upstream}.tar.gz"
expected_sha = checksums.get(tarball_name)
if not expected_sha:
raise RuntimeError(
f"{tarball_name} missing from SHA256SUMS (is the version tag correct?)"
)
tarball_path = tmp_dir / tarball_name
url = f"{LIMA_RELEASE_BASE}/{tag}/{tarball_name}"
log_info(f"Downloading {url}")
_download(url, tarball_path)
actual_sha = _sha256_file(tarball_path)
if actual_sha != expected_sha:
raise RuntimeError(
f"sha256 mismatch for {tarball_name}: "
f"expected {expected_sha}, got {actual_sha}"
)
limactl_path = tmp_dir / f"limactl-darwin-{arch.internal}"
_extract_limactl(tarball_path, limactl_path)
object_sha = _sha256_file(limactl_path)
r2_key = f"{LIMA_R2_PREFIX}/limactl-darwin-{arch.internal}"
if dry_run:
log_info(f"[dry-run] skipped upload of {r2_key}")
else:
if not upload_file_to_r2(client, limactl_path, r2_key, env.r2_bucket):
raise RuntimeError(f"Failed to upload {r2_key}")
return actual_sha, object_sha, r2_key
def _extract_limactl(tarball_path: Path, dest: Path) -> None:
"""Extract the single `bin/limactl` entry to dest."""
with tarfile.open(tarball_path, "r:gz") as tar:
member = _find_limactl_member(tar)
extracted = tar.extractfile(member)
if extracted is None:
raise RuntimeError(f"{member.name} is not a regular file")
with extracted as src, open(dest, "wb") as out:
while chunk := src.read(1024 * 1024):
out.write(chunk)
dest.chmod(0o755)
def _find_limactl_member(tar: tarfile.TarFile) -> tarfile.TarInfo:
for member in tar.getmembers():
if not member.isfile():
continue
parts = Path(member.name).parts
if len(parts) >= 2 and parts[-2:] == ("bin", "limactl"):
return member
raise RuntimeError("bin/limactl not found in Lima tarball")
def _build_manifest(
tag: str,
tarball_shas: Dict[str, str],
object_shas: Dict[str, str],
) -> Dict[str, Any]:
return {
"lima_version": tag,
"tarball_shas_upstream": tarball_shas,
"r2_object_shas": object_shas,
"uploaded_at": datetime.now(timezone.utc).isoformat(),
# Prefer CI context so we don't leak an individual's OS login when
# running locally. manifest.json is surfaced via the public CDN.
"uploaded_by": os.environ.get("GITHUB_ACTOR") or "local",
}
def _upload_manifest(
client: Any,
env: EnvConfig,
manifest: Dict[str, Any],
tmp_dir: Path,
dry_run: bool,
) -> None:
manifest_path = tmp_dir / "manifest.json"
manifest_path.write_text(
json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
)
if dry_run:
log_info(f"[dry-run] manifest would be: {manifest}")
return
if not upload_file_to_r2(client, manifest_path, LIMA_MANIFEST_KEY, env.r2_bucket):
raise RuntimeError(f"Failed to upload {LIMA_MANIFEST_KEY}")
def _rollback(client: Any, bucket: str, keys: List[str]) -> None:
for key in keys:
try:
client.delete_object(Bucket=bucket, Key=key)
log_info(f"Rolled back {key}")
except Exception as exc:
log_warning(f"Rollback failed for {key}: {exc}")
def _download(url: str, dest: Path, *, timeout: Optional[int] = None) -> None:
response = requests.get(url, stream=True, timeout=timeout or LIMA_HTTP_TIMEOUT_S)
response.raise_for_status()
dest.parent.mkdir(parents=True, exist_ok=True)
with open(dest, "wb") as out:
for chunk in response.iter_content(chunk_size=1024 * 1024):
if chunk:
out.write(chunk)
def _sha256_file(path: Path) -> str:
sha = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
sha.update(chunk)
return sha.hexdigest()

View File

@@ -1,246 +0,0 @@
#!/usr/bin/env python3
"""Tests for the Lima R2 uploader CLI."""
import hashlib
import io
import tarfile
import tempfile
import unittest
from pathlib import Path
from typing import Any, List, Tuple
from unittest import mock
from build.cli import storage
def _build_lima_tarball(version: str, payload: bytes) -> bytes:
"""Return a gzipped tar containing `lima-<v>/bin/limactl` with `payload`."""
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
info = tarfile.TarInfo(name=f"lima-{version}/bin/limactl")
info.size = len(payload)
info.mode = 0o755
tar.addfile(info, io.BytesIO(payload))
return buffer.getvalue()
class ParseChecksumsTest(unittest.TestCase):
def test_parses_two_column_lines(self) -> None:
contents = (
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa lima-1.2.3-Darwin-arm64.tar.gz\n"
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb *lima-1.2.3-Darwin-x86_64.tar.gz\n"
)
entries = storage._parse_checksums(contents)
self.assertEqual(
entries["lima-1.2.3-Darwin-arm64.tar.gz"],
"a" * 64,
)
self.assertEqual(
entries["lima-1.2.3-Darwin-x86_64.tar.gz"],
"b" * 64,
)
def test_ignores_blank_lines(self) -> None:
contents = "\n\n" + "c" * 64 + " lima-1.0.0-Darwin-arm64.tar.gz\n\n"
entries = storage._parse_checksums(contents)
self.assertEqual(list(entries), ["lima-1.0.0-Darwin-arm64.tar.gz"])
def test_rejects_malformed_lines(self) -> None:
with self.assertRaisesRegex(RuntimeError, "Malformed"):
storage._parse_checksums("just-one-token\n")
def test_rejects_non_sha256(self) -> None:
with self.assertRaisesRegex(RuntimeError, "Invalid sha256"):
storage._parse_checksums("xyz foo.tar.gz\n")
class NormalizeVersionTagTest(unittest.TestCase):
def test_keeps_existing_v_prefix(self) -> None:
self.assertEqual(storage._normalize_version_tag("v1.2.3"), "v1.2.3")
def test_adds_v_prefix_when_missing(self) -> None:
self.assertEqual(storage._normalize_version_tag("1.2.3"), "v1.2.3")
class ExtractLimactlTest(unittest.TestCase):
def test_extracts_limactl_binary(self) -> None:
payload = b"limactl-bytes-" + b"x" * 100
tarball = _build_lima_tarball("1.2.3", payload)
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
tarball_path = tmp_path / "lima.tar.gz"
tarball_path.write_bytes(tarball)
dest = tmp_path / "limactl"
storage._extract_limactl(tarball_path, dest)
self.assertEqual(dest.read_bytes(), payload)
self.assertTrue(dest.stat().st_mode & 0o100, "should be executable")
def test_raises_when_limactl_missing(self) -> None:
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
info = tarfile.TarInfo(name="lima-1.2.3/README")
info.size = 5
tar.addfile(info, io.BytesIO(b"hello"))
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
tarball_path = tmp_path / "lima.tar.gz"
tarball_path.write_bytes(buffer.getvalue())
with self.assertRaisesRegex(RuntimeError, "bin/limactl not found"):
storage._extract_limactl(tarball_path, tmp_path / "out")
class RollbackTest(unittest.TestCase):
def test_rollback_deletes_all_keys(self) -> None:
deleted: List[Tuple[str, str]] = []
class FakeClient:
def delete_object(self, **kwargs: str) -> None:
deleted.append((kwargs["Bucket"], kwargs["Key"]))
storage._rollback(FakeClient(), "browseros", ["a", "b", "c"])
self.assertEqual(deleted, [("browseros", "a"), ("browseros", "b"), ("browseros", "c")])
def test_rollback_tolerates_delete_failures(self) -> None:
class FakeClient:
def delete_object(self, **kwargs: str) -> None:
raise RuntimeError("boom")
# Should not raise — it logs a warning and moves on.
storage._rollback(FakeClient(), "browseros", ["a"])
class BuildManifestTest(unittest.TestCase):
def test_manifest_shape(self) -> None:
manifest = storage._build_manifest(
"v1.2.3",
{"arm64": "a" * 64, "x64": "b" * 64},
{"arm64": "c" * 64, "x64": "d" * 64},
)
self.assertEqual(manifest["lima_version"], "v1.2.3")
self.assertEqual(manifest["tarball_shas_upstream"]["arm64"], "a" * 64)
self.assertEqual(manifest["r2_object_shas"]["x64"], "d" * 64)
self.assertIn("uploaded_at", manifest)
self.assertIn("uploaded_by", manifest)
class ProcessArchTest(unittest.TestCase):
"""Covers download + sha verify + extract + upload in one pass."""
def setUp(self) -> None:
self.payload = b"limactl-binary-" + b"z" * 200
self.tarball_bytes = _build_lima_tarball("1.2.3", self.payload)
self.expected_tarball_sha = hashlib.sha256(self.tarball_bytes).hexdigest()
self.expected_object_sha = hashlib.sha256(self.payload).hexdigest()
def _fake_download(self, _url: str, dest: Path, **_kwargs: Any) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(self.tarball_bytes)
def test_happy_path_uploads_and_returns_shas(self) -> None:
uploads: List[Tuple[str, str]] = []
def fake_upload(_client: Any, _local_path: Path, r2_key: str, bucket: str) -> bool:
uploads.append((r2_key, bucket))
return True
env = mock.Mock(r2_bucket="browseros")
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
with mock.patch.object(storage, "_download", side_effect=self._fake_download), \
mock.patch.object(storage, "upload_file_to_r2", side_effect=fake_upload):
tarball_sha, object_sha, r2_key = storage._process_arch(
tag="v1.2.3",
arch=storage.LimaArch(internal="arm64", upstream="Darwin-arm64"),
tmp_dir=tmp_path,
checksums={
"lima-1.2.3-Darwin-arm64.tar.gz": self.expected_tarball_sha
},
client=mock.Mock(),
env=env,
dry_run=False,
)
self.assertEqual(tarball_sha, self.expected_tarball_sha)
self.assertEqual(object_sha, self.expected_object_sha)
self.assertEqual(r2_key, "third_party/lima/limactl-darwin-arm64")
self.assertEqual(uploads, [("third_party/lima/limactl-darwin-arm64", "browseros")])
def test_sha_mismatch_aborts_before_upload(self) -> None:
uploads: List[Tuple[str, str]] = []
def fake_upload(_client: Any, _local_path: Path, r2_key: str, bucket: str) -> bool:
uploads.append((r2_key, bucket))
return True
env = mock.Mock(r2_bucket="browseros")
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
with mock.patch.object(storage, "_download", side_effect=self._fake_download), \
mock.patch.object(storage, "upload_file_to_r2", side_effect=fake_upload):
with self.assertRaisesRegex(RuntimeError, "sha256 mismatch"):
storage._process_arch(
tag="v1.2.3",
arch=storage.LimaArch(internal="arm64", upstream="Darwin-arm64"),
tmp_dir=tmp_path,
checksums={"lima-1.2.3-Darwin-arm64.tar.gz": "0" * 64},
client=mock.Mock(),
env=env,
dry_run=False,
)
self.assertEqual(uploads, [])
def test_missing_checksum_entry_aborts(self) -> None:
env = mock.Mock(r2_bucket="browseros")
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
with self.assertRaisesRegex(RuntimeError, "missing from SHA256SUMS"):
storage._process_arch(
tag="v1.2.3",
arch=storage.LimaArch(internal="arm64", upstream="Darwin-arm64"),
tmp_dir=tmp_path,
checksums={},
client=mock.Mock(),
env=env,
dry_run=False,
)
def test_dry_run_skips_upload(self) -> None:
uploads: List[Tuple[str, str]] = []
def fake_upload(*args: Any, **kwargs: Any) -> bool:
uploads.append(("called", ""))
return True
env = mock.Mock(r2_bucket="browseros")
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
with mock.patch.object(storage, "_download", side_effect=self._fake_download), \
mock.patch.object(storage, "upload_file_to_r2", side_effect=fake_upload):
_, _, r2_key = storage._process_arch(
tag="v1.2.3",
arch=storage.LimaArch(internal="arm64", upstream="Darwin-arm64"),
tmp_dir=tmp_path,
checksums={
"lima-1.2.3-Darwin-arm64.tar.gz": self.expected_tarball_sha
},
client=None,
env=env,
dry_run=True,
)
self.assertEqual(uploads, [])
self.assertEqual(r2_key, "third_party/lima/limactl-darwin-arm64")
if __name__ == "__main__":
unittest.main()

View File

@@ -30,17 +30,26 @@ MACOS_SERVER_BINARIES: Dict[str, SignSpec] = {
),
"bun": SignSpec("bun", "runtime", "browseros-executable-entitlements.plist"),
"rg": SignSpec("rg", "runtime"),
"limactl": SignSpec("limactl", "runtime"),
"podman": SignSpec("podman", "runtime"),
"gvproxy": SignSpec("gvproxy", "runtime"),
"vfkit": SignSpec("vfkit", "runtime", "podman-vfkit-entitlements.plist"),
"krunkit": SignSpec("krunkit", "runtime", "podman-krunkit-entitlements.plist"),
"podman-mac-helper": SignSpec("podman_mac_helper", "runtime"),
}
WINDOWS_SERVER_BINARIES: List[str] = [
"browseros_server.exe",
"third_party/bun.exe",
"third_party/rg.exe",
"third_party/podman/podman.exe",
"third_party/podman/gvproxy.exe",
"third_party/podman/win-sshproxy.exe",
]
def macos_sign_spec_for(binary_path: Path) -> Optional[SignSpec]:
"""Look up sign metadata by file stem (e.g., ``limactl``)."""
"""Look up sign metadata by file stem (e.g., ``podman-mac-helper``)."""
return MACOS_SERVER_BINARIES.get(binary_path.stem)

View File

@@ -28,17 +28,14 @@ class MacosServerBinariesTest(unittest.TestCase):
self.assertTrue(plist.exists(), f"{stem}: entitlements {plist} missing")
def test_macos_sign_spec_for_resolves_by_stem(self):
spec = macos_sign_spec_for(Path("/x/limactl"))
spec = macos_sign_spec_for(Path("/x/podman-mac-helper"))
assert spec is not None
self.assertEqual(spec.identifier_suffix, "limactl")
self.assertEqual(spec.identifier_suffix, "podman_mac_helper")
self.assertIsNone(macos_sign_spec_for(Path("/x/not_a_known_binary")))
def test_matches_lima_bundle_layout(self):
keys = set(MACOS_SERVER_BINARIES.keys())
self.assertIn("limactl", keys)
forbidden = {"podman", "gvproxy", "vfkit", "krunkit", "podman-mac-helper"}
leftover = forbidden & keys
self.assertFalse(leftover, f"podman-era entries still present: {leftover}")
def test_matches_podman_bundle_layout(self):
required = {"podman", "gvproxy", "vfkit", "krunkit", "podman-mac-helper"}
self.assertTrue(required.issubset(MACOS_SERVER_BINARIES.keys()))
class WindowsServerBinariesTest(unittest.TestCase):
@@ -61,17 +58,6 @@ class WindowsServerBinariesTest(unittest.TestCase):
for rel, abs_path in zip(WINDOWS_SERVER_BINARIES, resolved):
self.assertEqual(abs_path, root / rel)
def test_windows_has_no_stale_third_party(self):
forbidden = {
"third_party/podman/podman.exe",
"third_party/podman/gvproxy.exe",
"third_party/podman/win-sshproxy.exe",
"third_party/bun.exe",
"third_party/rg.exe",
}
leftover = forbidden & set(WINDOWS_SERVER_BINARIES)
self.assertFalse(leftover, f"stale entries still present: {leftover}")
if __name__ == "__main__":
unittest.main()

View File

@@ -239,7 +239,7 @@ def create_server_bundle_zip(resources_dir: Path, output_zip: Path) -> bool:
"""Zip an extracted ``resources/`` tree into a Sparkle payload.
Produces entries like ``resources/bin/browseros_server``,
``resources/bin/third_party/lima/limactl`` — mirroring what the agent
``resources/bin/third_party/podman/podman`` — mirroring what the agent
build staged and what the Chromium build bakes into the installed app.
File modes are preserved by ``ZipFile.write`` so executable bits survive.
"""

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.hypervisor</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.virtualization</key>
<true/>
</dict>
</plist>