mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
7 Commits
fix/contai
...
feat/progr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
354b074780 | ||
|
|
678d9368d6 | ||
|
|
865ef21b5b | ||
|
|
4cba9e2020 | ||
|
|
d343fd1735 | ||
|
|
63fed8e79b | ||
|
|
1fdad55b4a |
158
.github/workflows/build-agent.yml
vendored
158
.github/workflows/build-agent.yml
vendored
@@ -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"
|
||||
179
.github/workflows/build-vm.yml
vendored
179
.github/workflows/build-vm.yml
vendored
@@ -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"
|
||||
1
packages/browseros-agent/.gitignore
vendored
1
packages/browseros-agent/.gitignore
vendored
@@ -14,7 +14,6 @@ lerna-debug.log*
|
||||
# Ignore all .env files except .env.example
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
!**/.env.sample
|
||||
!**/.env.production.example
|
||||
|
||||
|
||||
|
||||
@@ -218,9 +218,3 @@ This uses the same element resolution as the server's MCP tools — no coordinat
|
||||
The `<target>` argument can be:
|
||||
- An **index** from the `targets` output (e.g., `3`)
|
||||
- A **URL substring** (e.g., `sidepanel`, `newtab`, `chrome-extension://`)
|
||||
|
||||
## Release gating — bundled-VM runtime migration (2026-Q2)
|
||||
|
||||
Between the Lima server-prod-resources cutover (WS3) and the ContainerRuntime migration (WS6) landing, `resources/bin/third_party/` ships `limactl` instead of `podman`. The current OpenClaw runtime (`apps/server/src/api/services/openclaw/podman-runtime.ts`, `container-runtime.ts`) still invokes `podman`; it will fail to find the binary on builds cut from `dev`.
|
||||
|
||||
Do **not** cut a release branch off `dev` during this window. Track WS6 progress before any release cut. See `specs/bundled-vm-runtime-spec.md` + `specs/workstreams.md` for context.
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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
|
||||
@@ -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/`.
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"vmVersion": "2026.04.22",
|
||||
"agents": [
|
||||
{
|
||||
"name": "openclaw",
|
||||
"image": "ghcr.io/openclaw/openclaw",
|
||||
"version": "2026.4.12"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
ef5acb5908f6ef1f7ffcf3a63913cdf618da3229ffa3b04e3727959e36bb9de1
|
||||
@@ -1 +0,0 @@
|
||||
e879ce3547728da306bb0e634ee9f8309b8923b75873bf44cac161853b170f2b
|
||||
@@ -1 +0,0 @@
|
||||
v1.2.0
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
browseros ALL=(ALL) NOPASSWD: ALL
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { parseArch, podmanArch } from './common/arch'
|
||||
import { type Bundle, tarballKey } from './common/manifest'
|
||||
import { sha256File } from './common/sha256'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
agent: { type: 'string' },
|
||||
arch: { type: 'string' },
|
||||
'output-dir': { type: 'string', default: './dist/images' },
|
||||
},
|
||||
})
|
||||
|
||||
if (!values.agent || !values.arch) {
|
||||
console.error(
|
||||
'usage: build:tarball -- --agent <name> --arch <arm64|x64> [--output-dir ./dist/images]',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const arch = parseArch(values.arch)
|
||||
const outDir = values['output-dir']
|
||||
await mkdir(outDir, { recursive: true })
|
||||
|
||||
const pkgRoot = path.resolve(import.meta.dir, '..')
|
||||
const bundle = JSON.parse(
|
||||
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
|
||||
) as Bundle
|
||||
const agent = bundle.agents.find(({ name }) => name === values.agent)
|
||||
if (!agent) throw new Error(`unknown agent: ${values.agent}`)
|
||||
|
||||
const ref = `${agent.image}:${agent.version}`
|
||||
const tarballPath = path.join(
|
||||
outDir,
|
||||
path.basename(tarballKey(agent.name, agent.version, arch)),
|
||||
)
|
||||
const tarPath = tarballPath.slice(0, -'.gz'.length)
|
||||
|
||||
await rm(tarballPath, { force: true })
|
||||
await rm(`${tarballPath}.sha256`, { force: true })
|
||||
await rm(tarPath, { force: true })
|
||||
await spawnChecked([
|
||||
'podman',
|
||||
'pull',
|
||||
'--os',
|
||||
'linux',
|
||||
'--arch',
|
||||
podmanArch(arch),
|
||||
ref,
|
||||
])
|
||||
await spawnChecked([
|
||||
'podman',
|
||||
'save',
|
||||
'--format=oci-archive',
|
||||
'--output',
|
||||
tarPath,
|
||||
ref,
|
||||
])
|
||||
await spawnChecked(['gzip', '-9', '-f', tarPath])
|
||||
|
||||
const sha = await sha256File(tarballPath)
|
||||
const size = (await stat(tarballPath)).size
|
||||
await writeFile(
|
||||
`${tarballPath}.sha256`,
|
||||
`${sha} ${path.basename(tarballPath)}\n`,
|
||||
)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
key: tarballKey(agent.name, agent.version, arch),
|
||||
path: tarballPath,
|
||||
sha256: sha,
|
||||
sizeBytes: size,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
async function spawnChecked(argv: string[]): Promise<void> {
|
||||
const proc = Bun.spawn(argv, {
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
})
|
||||
const code = await proc.exited
|
||||
if (code !== 0) throw new Error(`${argv[0]} exited ${code}`)
|
||||
}
|
||||
@@ -1,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)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export type Arch = 'arm64' | 'x64'
|
||||
|
||||
export const ARCHES: readonly Arch[] = ['arm64', 'x64']
|
||||
|
||||
export function parseArch(raw: string): Arch {
|
||||
if (raw === 'arm64' || raw === 'x64') return raw
|
||||
throw new Error(`unknown arch: ${raw} (expected arm64|x64)`)
|
||||
}
|
||||
|
||||
export function podmanArch(arch: Arch): 'arm64' | 'amd64' {
|
||||
return arch === 'x64' ? 'amd64' : 'arm64'
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
export async function fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs = 30_000,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: init.signal ?? controller.signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if ((error as { name?: string }).name === 'AbortError') {
|
||||
throw new Error(`fetch timed out after ${timeoutMs}ms: ${url}`)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
@@ -1,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,
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import {
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3'
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) throw new Error(`missing env var: ${name}`)
|
||||
return value
|
||||
}
|
||||
|
||||
export function createR2Client(): S3Client {
|
||||
return new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${required('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: required('R2_ACCESS_KEY_ID'),
|
||||
secretAccessKey: required('R2_SECRET_ACCESS_KEY'),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getBucket(): string {
|
||||
return required('R2_BUCKET')
|
||||
}
|
||||
|
||||
export function getCdnBase(): string {
|
||||
return process.env.R2_PUBLIC_BASE_URL?.trim() ?? 'https://cdn.browseros.com'
|
||||
}
|
||||
|
||||
export async function putFile(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
filePath: string,
|
||||
contentType: string,
|
||||
): Promise<void> {
|
||||
const { size } = await stat(filePath)
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: createReadStream(filePath),
|
||||
ContentLength: size,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function putBody(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
body: string,
|
||||
contentType: string,
|
||||
): Promise<void> {
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentLength: Buffer.byteLength(body),
|
||||
ContentType: contentType,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function getBody(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({ Bucket: bucket, Key: key }),
|
||||
)
|
||||
const body = response.Body as
|
||||
| { transformToByteArray(): Promise<Uint8Array> }
|
||||
| undefined
|
||||
if (!body) throw new Error(`missing response body for R2 key: ${key}`)
|
||||
const bytes = await body.transformToByteArray()
|
||||
return new TextDecoder().decode(bytes)
|
||||
} catch (error) {
|
||||
const cause = error as {
|
||||
name?: string
|
||||
$metadata?: { httpStatusCode?: number }
|
||||
}
|
||||
if (cause.name === 'NoSuchKey' || cause.$metadata?.httpStatusCode === 404) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
|
||||
export async function sha256File(path: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(path)) {
|
||||
hash.update(chunk)
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
export async function verifySha256(
|
||||
path: string,
|
||||
expected: string,
|
||||
): Promise<void> {
|
||||
const actual = await sha256File(path)
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`sha256 mismatch for ${path}: expected ${expected}, got ${actual}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { createR2Client, getBody, getBucket } from './common/r2'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
key: { type: 'string' },
|
||||
out: { type: 'string' },
|
||||
},
|
||||
})
|
||||
|
||||
if (!values.key || !values.out) {
|
||||
console.error('usage: download -- --key <r2-key> --out <path>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const body = await getBody(createR2Client(), getBucket(), values.key)
|
||||
if (body === null) {
|
||||
throw new Error(
|
||||
`R2 key not found: ${values.key}. Publish a full manifest before publishing slices.`,
|
||||
)
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(values.out), { recursive: true })
|
||||
await writeFile(values.out, body)
|
||||
console.log(`downloaded ${values.key} to ${values.out}`)
|
||||
@@ -1,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
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
import { createReadStream, createWriteStream } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { createGunzip } from 'node:zlib'
|
||||
import { parseArch, podmanArch } from './common/arch'
|
||||
import type { Bundle } from './common/manifest'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
agent: { type: 'string' },
|
||||
arch: { type: 'string' },
|
||||
tarball: { type: 'string' },
|
||||
},
|
||||
})
|
||||
|
||||
if (!values.agent || !values.arch || !values.tarball) {
|
||||
console.error(
|
||||
'usage: smoke:tarball -- --agent <name> --arch <arm64|x64> --tarball <path.tar.gz>',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const arch = parseArch(values.arch)
|
||||
const pkgRoot = path.resolve(import.meta.dir, '..')
|
||||
const bundle = JSON.parse(
|
||||
await readFile(path.join(pkgRoot, 'bundle.json'), 'utf8'),
|
||||
) as Bundle
|
||||
const agent = bundle.agents.find(({ name }) => name === values.agent)
|
||||
if (!agent) throw new Error(`unknown agent: ${values.agent}`)
|
||||
|
||||
const ref = `${agent.image}:${agent.version}`
|
||||
const tarball = await maybeDecompress(values.tarball)
|
||||
|
||||
try {
|
||||
await spawnChecked(['podman', 'rmi', '-f', ref]).catch(() => {})
|
||||
await spawnChecked(['podman', 'load', '--input', tarball.path])
|
||||
const inspected = await inspectImage(ref)
|
||||
if (inspected.Os !== 'linux') {
|
||||
throw new Error(`expected linux image, got ${inspected.Os ?? '<missing>'}`)
|
||||
}
|
||||
if (inspected.Architecture !== podmanArch(arch)) {
|
||||
throw new Error(
|
||||
`expected ${podmanArch(arch)} image, got ${inspected.Architecture ?? '<missing>'}`,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await spawnChecked(['podman', 'rmi', '-f', ref]).catch(() => {})
|
||||
if (tarball.cleanupDir) {
|
||||
await rm(tarball.cleanupDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
console.log('tarball smoke test passed')
|
||||
|
||||
async function maybeDecompress(
|
||||
tarballPath: string,
|
||||
): Promise<{ path: string; cleanupDir?: string }> {
|
||||
if (!tarballPath.endsWith('.gz')) return { path: tarballPath }
|
||||
|
||||
const cleanupDir = await mkdtemp(path.join(tmpdir(), 'browseros-tar-smoke-'))
|
||||
const tarPath = path.join(cleanupDir, 'image.tar')
|
||||
await pipeline(
|
||||
createReadStream(tarballPath),
|
||||
createGunzip(),
|
||||
createWriteStream(tarPath),
|
||||
)
|
||||
return { path: tarPath, cleanupDir }
|
||||
}
|
||||
|
||||
async function inspectImage(ref: string): Promise<{
|
||||
Architecture?: string
|
||||
Os?: string
|
||||
}> {
|
||||
const stdout = await spawnCapture([
|
||||
'podman',
|
||||
'inspect',
|
||||
'--type',
|
||||
'image',
|
||||
'--format',
|
||||
'{{json .}}',
|
||||
ref,
|
||||
])
|
||||
return JSON.parse(stdout) as { Architecture?: string; Os?: string }
|
||||
}
|
||||
|
||||
async function spawnCapture(argv: string[]): Promise<string> {
|
||||
const proc = Bun.spawn(argv, { stdout: 'pipe', stderr: 'pipe' })
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
])
|
||||
if (code !== 0) {
|
||||
throw new Error(
|
||||
`${argv[0]} exited ${code}\n${stderr.trim() || stdout.trim()}`,
|
||||
)
|
||||
}
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
async function spawnChecked(argv: string[]): Promise<void> {
|
||||
await spawnCapture(argv)
|
||||
}
|
||||
@@ -1,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}`)
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
import { parseArgs } from 'node:util'
|
||||
import { createR2Client, getBucket, putBody, putFile } from './common/r2'
|
||||
import { sha256File } from './common/sha256'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
file: { type: 'string' },
|
||||
key: { type: 'string' },
|
||||
'content-type': { type: 'string' },
|
||||
'sidecar-sha': { type: 'boolean' },
|
||||
},
|
||||
})
|
||||
|
||||
if (!values.file || !values.key) {
|
||||
throw new Error('--file and --key required')
|
||||
}
|
||||
|
||||
const contentType = values['content-type'] ?? 'application/octet-stream'
|
||||
const client = createR2Client()
|
||||
const bucket = getBucket()
|
||||
|
||||
try {
|
||||
await putFile(client, bucket, values.key, values.file, contentType)
|
||||
console.log(`uploaded ${values.file} to ${bucket}/${values.key}`)
|
||||
|
||||
if (values['sidecar-sha']) {
|
||||
const sha = await sha256File(values.file)
|
||||
const filename = values.file.split('/').pop() ?? values.file
|
||||
await putBody(
|
||||
client,
|
||||
bucket,
|
||||
`${values.key}.sha256`,
|
||||
`${sha} ${filename}\n`,
|
||||
'text/plain; charset=utf-8',
|
||||
)
|
||||
console.log(`uploaded sha256 to ${bucket}/${values.key}.sha256`)
|
||||
}
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { parseArch, podmanArch } from '../scripts/common/arch'
|
||||
|
||||
describe('arch helpers', () => {
|
||||
it('normalizes BrowserOS arches for podman', () => {
|
||||
expect(podmanArch('arm64')).toBe('arm64')
|
||||
expect(podmanArch('x64')).toBe('amd64')
|
||||
})
|
||||
|
||||
it('parses supported release arches', () => {
|
||||
expect(parseArch('arm64')).toBe('arm64')
|
||||
expect(parseArch('x64')).toBe('x64')
|
||||
})
|
||||
|
||||
it('rejects unsupported release arches', () => {
|
||||
expect(() => parseArch('amd64')).toThrow('unknown arch: amd64')
|
||||
})
|
||||
})
|
||||
@@ -1,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 }
|
||||
}
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["scripts/**/*", "tests/**/*", "package.json", "bundle.json"]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
4
packages/browseros/build/browseros.py
generated
4
packages/browseros/build/browseros.py
generated
@@ -44,10 +44,6 @@ app.add_typer(release.app, name="release", help="Release automation")
|
||||
from .cli import ota
|
||||
app.add_typer(ota.app, name="ota", help="OTA update automation")
|
||||
|
||||
# Third-party resource uploads (Lima, future VM disk + agent tarballs)
|
||||
from .cli import storage
|
||||
app.add_typer(storage.app, name="upload", help="Upload third-party resources to R2")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
||||
269
packages/browseros/build/cli/storage.py
generated
269
packages/browseros/build/cli/storage.py
generated
@@ -1,269 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Storage CLI - Push third-party resources to R2 for build:server ingestion."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tarfile
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
import typer
|
||||
|
||||
from ..common.env import EnvConfig
|
||||
from ..common.utils import log_error, log_info, log_success, log_warning
|
||||
from ..modules.storage.r2 import (
|
||||
BOTO3_AVAILABLE,
|
||||
get_r2_client,
|
||||
upload_file_to_r2,
|
||||
)
|
||||
|
||||
LIMA_RELEASE_BASE = "https://github.com/lima-vm/lima/releases/download"
|
||||
LIMA_R2_PREFIX = "third_party/lima"
|
||||
LIMA_MANIFEST_KEY = f"{LIMA_R2_PREFIX}/manifest.json"
|
||||
LIMA_HTTP_TIMEOUT_S = 60
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LimaArch:
|
||||
"""Arch-pair: the suffix Lima uses upstream and the suffix we use in R2."""
|
||||
|
||||
internal: str # "arm64" | "x64" — how our R2 keys name it
|
||||
upstream: str # "Darwin-arm64" | "Darwin-x86_64" — Lima's tarball suffix
|
||||
|
||||
|
||||
LIMA_ARCHES: Tuple[LimaArch, ...] = (
|
||||
LimaArch(internal="arm64", upstream="Darwin-arm64"),
|
||||
LimaArch(internal="x64", upstream="Darwin-x86_64"),
|
||||
)
|
||||
|
||||
|
||||
app = typer.Typer(
|
||||
help="Upload third-party resources to Cloudflare R2",
|
||||
pretty_exceptions_enable=False,
|
||||
pretty_exceptions_show_locals=False,
|
||||
)
|
||||
|
||||
|
||||
@app.command("lima")
|
||||
def upload_lima(
|
||||
version: str = typer.Option(
|
||||
...,
|
||||
"--version",
|
||||
"-v",
|
||||
help="Lima release tag, e.g. v1.2.3",
|
||||
),
|
||||
dry_run: bool = typer.Option(
|
||||
False,
|
||||
"--dry-run",
|
||||
help="Download + verify only; skip R2 uploads.",
|
||||
),
|
||||
) -> None:
|
||||
"""Download limactl from a Lima GitHub release and push to R2."""
|
||||
if not BOTO3_AVAILABLE:
|
||||
log_error("boto3 not installed — run: pip install boto3")
|
||||
raise typer.Exit(1)
|
||||
|
||||
env = EnvConfig()
|
||||
if not env.has_r2_config():
|
||||
log_error(
|
||||
"R2 configuration missing. Required: "
|
||||
"R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
tag = _normalize_version_tag(version)
|
||||
client = None if dry_run else get_r2_client(env)
|
||||
if not dry_run and client is None:
|
||||
log_error("Failed to create R2 client")
|
||||
raise typer.Exit(1)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="lima-upload-") as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
checksums = _fetch_checksums(tag, tmp_dir)
|
||||
uploaded_keys: List[str] = []
|
||||
object_shas: Dict[str, str] = {}
|
||||
tarball_shas: Dict[str, str] = {}
|
||||
|
||||
try:
|
||||
for arch in LIMA_ARCHES:
|
||||
tarball_sha, object_sha, r2_key = _process_arch(
|
||||
tag, arch, tmp_dir, checksums, client, env, dry_run
|
||||
)
|
||||
tarball_shas[arch.internal] = tarball_sha
|
||||
object_shas[arch.internal] = object_sha
|
||||
if not dry_run:
|
||||
uploaded_keys.append(r2_key)
|
||||
|
||||
manifest = _build_manifest(tag, tarball_shas, object_shas)
|
||||
_upload_manifest(client, env, manifest, tmp_dir, dry_run)
|
||||
# Any failure mid-loop (download, sha verify, extract, upload) must
|
||||
# roll back prior arch uploads so R2 never holds a mixed-version pair.
|
||||
except Exception as exc:
|
||||
if not dry_run and uploaded_keys:
|
||||
log_warning(f"Upload failed — rolling back {len(uploaded_keys)} object(s)")
|
||||
_rollback(client, env.r2_bucket, uploaded_keys)
|
||||
log_error(f"Lima upload aborted: {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
log_success(f"Lima {tag} uploaded for {[a.internal for a in LIMA_ARCHES]}")
|
||||
|
||||
|
||||
def _normalize_version_tag(version: str) -> str:
|
||||
return version if version.startswith("v") else f"v{version}"
|
||||
|
||||
|
||||
def _fetch_checksums(tag: str, tmp_dir: Path) -> Dict[str, str]:
|
||||
url = f"{LIMA_RELEASE_BASE}/{tag}/SHA256SUMS"
|
||||
dest = tmp_dir / "SHA256SUMS"
|
||||
log_info(f"Fetching {url}")
|
||||
_download(url, dest)
|
||||
return _parse_checksums(dest.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _parse_checksums(contents: str) -> Dict[str, str]:
|
||||
"""Parse lines like '<sha256> lima-1.2.3-Darwin-arm64.tar.gz'."""
|
||||
entries: Dict[str, str] = {}
|
||||
for raw_line in contents.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) != 2:
|
||||
raise RuntimeError(f"Malformed SHA256SUMS line: {raw_line!r}")
|
||||
sha, name = parts[0].lower(), parts[1].lstrip("*").strip()
|
||||
if len(sha) != 64 or not all(c in "0123456789abcdef" for c in sha):
|
||||
raise RuntimeError(f"Invalid sha256 in SHA256SUMS: {raw_line!r}")
|
||||
entries[name] = sha
|
||||
return entries
|
||||
|
||||
|
||||
def _process_arch(
|
||||
tag: str,
|
||||
arch: LimaArch,
|
||||
tmp_dir: Path,
|
||||
checksums: Dict[str, str],
|
||||
client: Any,
|
||||
env: EnvConfig,
|
||||
dry_run: bool,
|
||||
) -> Tuple[str, str, str]:
|
||||
version_num = tag.lstrip("v")
|
||||
tarball_name = f"lima-{version_num}-{arch.upstream}.tar.gz"
|
||||
expected_sha = checksums.get(tarball_name)
|
||||
if not expected_sha:
|
||||
raise RuntimeError(
|
||||
f"{tarball_name} missing from SHA256SUMS (is the version tag correct?)"
|
||||
)
|
||||
|
||||
tarball_path = tmp_dir / tarball_name
|
||||
url = f"{LIMA_RELEASE_BASE}/{tag}/{tarball_name}"
|
||||
log_info(f"Downloading {url}")
|
||||
_download(url, tarball_path)
|
||||
|
||||
actual_sha = _sha256_file(tarball_path)
|
||||
if actual_sha != expected_sha:
|
||||
raise RuntimeError(
|
||||
f"sha256 mismatch for {tarball_name}: "
|
||||
f"expected {expected_sha}, got {actual_sha}"
|
||||
)
|
||||
|
||||
limactl_path = tmp_dir / f"limactl-darwin-{arch.internal}"
|
||||
_extract_limactl(tarball_path, limactl_path)
|
||||
object_sha = _sha256_file(limactl_path)
|
||||
|
||||
r2_key = f"{LIMA_R2_PREFIX}/limactl-darwin-{arch.internal}"
|
||||
if dry_run:
|
||||
log_info(f"[dry-run] skipped upload of {r2_key}")
|
||||
else:
|
||||
if not upload_file_to_r2(client, limactl_path, r2_key, env.r2_bucket):
|
||||
raise RuntimeError(f"Failed to upload {r2_key}")
|
||||
|
||||
return actual_sha, object_sha, r2_key
|
||||
|
||||
|
||||
def _extract_limactl(tarball_path: Path, dest: Path) -> None:
|
||||
"""Extract the single `bin/limactl` entry to dest."""
|
||||
with tarfile.open(tarball_path, "r:gz") as tar:
|
||||
member = _find_limactl_member(tar)
|
||||
extracted = tar.extractfile(member)
|
||||
if extracted is None:
|
||||
raise RuntimeError(f"{member.name} is not a regular file")
|
||||
with extracted as src, open(dest, "wb") as out:
|
||||
while chunk := src.read(1024 * 1024):
|
||||
out.write(chunk)
|
||||
dest.chmod(0o755)
|
||||
|
||||
|
||||
def _find_limactl_member(tar: tarfile.TarFile) -> tarfile.TarInfo:
|
||||
for member in tar.getmembers():
|
||||
if not member.isfile():
|
||||
continue
|
||||
parts = Path(member.name).parts
|
||||
if len(parts) >= 2 and parts[-2:] == ("bin", "limactl"):
|
||||
return member
|
||||
raise RuntimeError("bin/limactl not found in Lima tarball")
|
||||
|
||||
|
||||
def _build_manifest(
|
||||
tag: str,
|
||||
tarball_shas: Dict[str, str],
|
||||
object_shas: Dict[str, str],
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"lima_version": tag,
|
||||
"tarball_shas_upstream": tarball_shas,
|
||||
"r2_object_shas": object_shas,
|
||||
"uploaded_at": datetime.now(timezone.utc).isoformat(),
|
||||
# Prefer CI context so we don't leak an individual's OS login when
|
||||
# running locally. manifest.json is surfaced via the public CDN.
|
||||
"uploaded_by": os.environ.get("GITHUB_ACTOR") or "local",
|
||||
}
|
||||
|
||||
|
||||
def _upload_manifest(
|
||||
client: Any,
|
||||
env: EnvConfig,
|
||||
manifest: Dict[str, Any],
|
||||
tmp_dir: Path,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
manifest_path = tmp_dir / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
if dry_run:
|
||||
log_info(f"[dry-run] manifest would be: {manifest}")
|
||||
return
|
||||
if not upload_file_to_r2(client, manifest_path, LIMA_MANIFEST_KEY, env.r2_bucket):
|
||||
raise RuntimeError(f"Failed to upload {LIMA_MANIFEST_KEY}")
|
||||
|
||||
|
||||
def _rollback(client: Any, bucket: str, keys: List[str]) -> None:
|
||||
for key in keys:
|
||||
try:
|
||||
client.delete_object(Bucket=bucket, Key=key)
|
||||
log_info(f"Rolled back {key}")
|
||||
except Exception as exc:
|
||||
log_warning(f"Rollback failed for {key}: {exc}")
|
||||
|
||||
|
||||
def _download(url: str, dest: Path, *, timeout: Optional[int] = None) -> None:
|
||||
response = requests.get(url, stream=True, timeout=timeout or LIMA_HTTP_TIMEOUT_S)
|
||||
response.raise_for_status()
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(dest, "wb") as out:
|
||||
for chunk in response.iter_content(chunk_size=1024 * 1024):
|
||||
if chunk:
|
||||
out.write(chunk)
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
sha = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
sha.update(chunk)
|
||||
return sha.hexdigest()
|
||||
246
packages/browseros/build/cli/storage_test.py
generated
246
packages/browseros/build/cli/storage_test.py
generated
@@ -1,246 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the Lima R2 uploader CLI."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Tuple
|
||||
from unittest import mock
|
||||
|
||||
from build.cli import storage
|
||||
|
||||
|
||||
def _build_lima_tarball(version: str, payload: bytes) -> bytes:
|
||||
"""Return a gzipped tar containing `lima-<v>/bin/limactl` with `payload`."""
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
|
||||
info = tarfile.TarInfo(name=f"lima-{version}/bin/limactl")
|
||||
info.size = len(payload)
|
||||
info.mode = 0o755
|
||||
tar.addfile(info, io.BytesIO(payload))
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
class ParseChecksumsTest(unittest.TestCase):
|
||||
def test_parses_two_column_lines(self) -> None:
|
||||
contents = (
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa lima-1.2.3-Darwin-arm64.tar.gz\n"
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb *lima-1.2.3-Darwin-x86_64.tar.gz\n"
|
||||
)
|
||||
entries = storage._parse_checksums(contents)
|
||||
self.assertEqual(
|
||||
entries["lima-1.2.3-Darwin-arm64.tar.gz"],
|
||||
"a" * 64,
|
||||
)
|
||||
self.assertEqual(
|
||||
entries["lima-1.2.3-Darwin-x86_64.tar.gz"],
|
||||
"b" * 64,
|
||||
)
|
||||
|
||||
def test_ignores_blank_lines(self) -> None:
|
||||
contents = "\n\n" + "c" * 64 + " lima-1.0.0-Darwin-arm64.tar.gz\n\n"
|
||||
entries = storage._parse_checksums(contents)
|
||||
self.assertEqual(list(entries), ["lima-1.0.0-Darwin-arm64.tar.gz"])
|
||||
|
||||
def test_rejects_malformed_lines(self) -> None:
|
||||
with self.assertRaisesRegex(RuntimeError, "Malformed"):
|
||||
storage._parse_checksums("just-one-token\n")
|
||||
|
||||
def test_rejects_non_sha256(self) -> None:
|
||||
with self.assertRaisesRegex(RuntimeError, "Invalid sha256"):
|
||||
storage._parse_checksums("xyz foo.tar.gz\n")
|
||||
|
||||
|
||||
class NormalizeVersionTagTest(unittest.TestCase):
|
||||
def test_keeps_existing_v_prefix(self) -> None:
|
||||
self.assertEqual(storage._normalize_version_tag("v1.2.3"), "v1.2.3")
|
||||
|
||||
def test_adds_v_prefix_when_missing(self) -> None:
|
||||
self.assertEqual(storage._normalize_version_tag("1.2.3"), "v1.2.3")
|
||||
|
||||
|
||||
class ExtractLimactlTest(unittest.TestCase):
|
||||
def test_extracts_limactl_binary(self) -> None:
|
||||
payload = b"limactl-bytes-" + b"x" * 100
|
||||
tarball = _build_lima_tarball("1.2.3", payload)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
tarball_path = tmp_path / "lima.tar.gz"
|
||||
tarball_path.write_bytes(tarball)
|
||||
dest = tmp_path / "limactl"
|
||||
|
||||
storage._extract_limactl(tarball_path, dest)
|
||||
|
||||
self.assertEqual(dest.read_bytes(), payload)
|
||||
self.assertTrue(dest.stat().st_mode & 0o100, "should be executable")
|
||||
|
||||
def test_raises_when_limactl_missing(self) -> None:
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
|
||||
info = tarfile.TarInfo(name="lima-1.2.3/README")
|
||||
info.size = 5
|
||||
tar.addfile(info, io.BytesIO(b"hello"))
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
tarball_path = tmp_path / "lima.tar.gz"
|
||||
tarball_path.write_bytes(buffer.getvalue())
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "bin/limactl not found"):
|
||||
storage._extract_limactl(tarball_path, tmp_path / "out")
|
||||
|
||||
|
||||
class RollbackTest(unittest.TestCase):
|
||||
def test_rollback_deletes_all_keys(self) -> None:
|
||||
deleted: List[Tuple[str, str]] = []
|
||||
|
||||
class FakeClient:
|
||||
def delete_object(self, **kwargs: str) -> None:
|
||||
deleted.append((kwargs["Bucket"], kwargs["Key"]))
|
||||
|
||||
storage._rollback(FakeClient(), "browseros", ["a", "b", "c"])
|
||||
self.assertEqual(deleted, [("browseros", "a"), ("browseros", "b"), ("browseros", "c")])
|
||||
|
||||
def test_rollback_tolerates_delete_failures(self) -> None:
|
||||
class FakeClient:
|
||||
def delete_object(self, **kwargs: str) -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
# Should not raise — it logs a warning and moves on.
|
||||
storage._rollback(FakeClient(), "browseros", ["a"])
|
||||
|
||||
|
||||
class BuildManifestTest(unittest.TestCase):
|
||||
def test_manifest_shape(self) -> None:
|
||||
manifest = storage._build_manifest(
|
||||
"v1.2.3",
|
||||
{"arm64": "a" * 64, "x64": "b" * 64},
|
||||
{"arm64": "c" * 64, "x64": "d" * 64},
|
||||
)
|
||||
self.assertEqual(manifest["lima_version"], "v1.2.3")
|
||||
self.assertEqual(manifest["tarball_shas_upstream"]["arm64"], "a" * 64)
|
||||
self.assertEqual(manifest["r2_object_shas"]["x64"], "d" * 64)
|
||||
self.assertIn("uploaded_at", manifest)
|
||||
self.assertIn("uploaded_by", manifest)
|
||||
|
||||
|
||||
class ProcessArchTest(unittest.TestCase):
|
||||
"""Covers download + sha verify + extract + upload in one pass."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.payload = b"limactl-binary-" + b"z" * 200
|
||||
self.tarball_bytes = _build_lima_tarball("1.2.3", self.payload)
|
||||
self.expected_tarball_sha = hashlib.sha256(self.tarball_bytes).hexdigest()
|
||||
self.expected_object_sha = hashlib.sha256(self.payload).hexdigest()
|
||||
|
||||
def _fake_download(self, _url: str, dest: Path, **_kwargs: Any) -> None:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(self.tarball_bytes)
|
||||
|
||||
def test_happy_path_uploads_and_returns_shas(self) -> None:
|
||||
uploads: List[Tuple[str, str]] = []
|
||||
|
||||
def fake_upload(_client: Any, _local_path: Path, r2_key: str, bucket: str) -> bool:
|
||||
uploads.append((r2_key, bucket))
|
||||
return True
|
||||
|
||||
env = mock.Mock(r2_bucket="browseros")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
with mock.patch.object(storage, "_download", side_effect=self._fake_download), \
|
||||
mock.patch.object(storage, "upload_file_to_r2", side_effect=fake_upload):
|
||||
tarball_sha, object_sha, r2_key = storage._process_arch(
|
||||
tag="v1.2.3",
|
||||
arch=storage.LimaArch(internal="arm64", upstream="Darwin-arm64"),
|
||||
tmp_dir=tmp_path,
|
||||
checksums={
|
||||
"lima-1.2.3-Darwin-arm64.tar.gz": self.expected_tarball_sha
|
||||
},
|
||||
client=mock.Mock(),
|
||||
env=env,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
self.assertEqual(tarball_sha, self.expected_tarball_sha)
|
||||
self.assertEqual(object_sha, self.expected_object_sha)
|
||||
self.assertEqual(r2_key, "third_party/lima/limactl-darwin-arm64")
|
||||
self.assertEqual(uploads, [("third_party/lima/limactl-darwin-arm64", "browseros")])
|
||||
|
||||
def test_sha_mismatch_aborts_before_upload(self) -> None:
|
||||
uploads: List[Tuple[str, str]] = []
|
||||
|
||||
def fake_upload(_client: Any, _local_path: Path, r2_key: str, bucket: str) -> bool:
|
||||
uploads.append((r2_key, bucket))
|
||||
return True
|
||||
|
||||
env = mock.Mock(r2_bucket="browseros")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
with mock.patch.object(storage, "_download", side_effect=self._fake_download), \
|
||||
mock.patch.object(storage, "upload_file_to_r2", side_effect=fake_upload):
|
||||
with self.assertRaisesRegex(RuntimeError, "sha256 mismatch"):
|
||||
storage._process_arch(
|
||||
tag="v1.2.3",
|
||||
arch=storage.LimaArch(internal="arm64", upstream="Darwin-arm64"),
|
||||
tmp_dir=tmp_path,
|
||||
checksums={"lima-1.2.3-Darwin-arm64.tar.gz": "0" * 64},
|
||||
client=mock.Mock(),
|
||||
env=env,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
self.assertEqual(uploads, [])
|
||||
|
||||
def test_missing_checksum_entry_aborts(self) -> None:
|
||||
env = mock.Mock(r2_bucket="browseros")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
with self.assertRaisesRegex(RuntimeError, "missing from SHA256SUMS"):
|
||||
storage._process_arch(
|
||||
tag="v1.2.3",
|
||||
arch=storage.LimaArch(internal="arm64", upstream="Darwin-arm64"),
|
||||
tmp_dir=tmp_path,
|
||||
checksums={},
|
||||
client=mock.Mock(),
|
||||
env=env,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
def test_dry_run_skips_upload(self) -> None:
|
||||
uploads: List[Tuple[str, str]] = []
|
||||
|
||||
def fake_upload(*args: Any, **kwargs: Any) -> bool:
|
||||
uploads.append(("called", ""))
|
||||
return True
|
||||
|
||||
env = mock.Mock(r2_bucket="browseros")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
with mock.patch.object(storage, "_download", side_effect=self._fake_download), \
|
||||
mock.patch.object(storage, "upload_file_to_r2", side_effect=fake_upload):
|
||||
_, _, r2_key = storage._process_arch(
|
||||
tag="v1.2.3",
|
||||
arch=storage.LimaArch(internal="arm64", upstream="Darwin-arm64"),
|
||||
tmp_dir=tmp_path,
|
||||
checksums={
|
||||
"lima-1.2.3-Darwin-arm64.tar.gz": self.expected_tarball_sha
|
||||
},
|
||||
client=None,
|
||||
env=env,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
self.assertEqual(uploads, [])
|
||||
self.assertEqual(r2_key, "third_party/lima/limactl-darwin-arm64")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
13
packages/browseros/build/common/server_binaries.py
generated
13
packages/browseros/build/common/server_binaries.py
generated
@@ -30,17 +30,26 @@ MACOS_SERVER_BINARIES: Dict[str, SignSpec] = {
|
||||
),
|
||||
"bun": SignSpec("bun", "runtime", "browseros-executable-entitlements.plist"),
|
||||
"rg": SignSpec("rg", "runtime"),
|
||||
"limactl": SignSpec("limactl", "runtime"),
|
||||
"podman": SignSpec("podman", "runtime"),
|
||||
"gvproxy": SignSpec("gvproxy", "runtime"),
|
||||
"vfkit": SignSpec("vfkit", "runtime", "podman-vfkit-entitlements.plist"),
|
||||
"krunkit": SignSpec("krunkit", "runtime", "podman-krunkit-entitlements.plist"),
|
||||
"podman-mac-helper": SignSpec("podman_mac_helper", "runtime"),
|
||||
}
|
||||
|
||||
|
||||
WINDOWS_SERVER_BINARIES: List[str] = [
|
||||
"browseros_server.exe",
|
||||
"third_party/bun.exe",
|
||||
"third_party/rg.exe",
|
||||
"third_party/podman/podman.exe",
|
||||
"third_party/podman/gvproxy.exe",
|
||||
"third_party/podman/win-sshproxy.exe",
|
||||
]
|
||||
|
||||
|
||||
def macos_sign_spec_for(binary_path: Path) -> Optional[SignSpec]:
|
||||
"""Look up sign metadata by file stem (e.g., ``limactl``)."""
|
||||
"""Look up sign metadata by file stem (e.g., ``podman-mac-helper``)."""
|
||||
return MACOS_SERVER_BINARIES.get(binary_path.stem)
|
||||
|
||||
|
||||
|
||||
@@ -28,17 +28,14 @@ class MacosServerBinariesTest(unittest.TestCase):
|
||||
self.assertTrue(plist.exists(), f"{stem}: entitlements {plist} missing")
|
||||
|
||||
def test_macos_sign_spec_for_resolves_by_stem(self):
|
||||
spec = macos_sign_spec_for(Path("/x/limactl"))
|
||||
spec = macos_sign_spec_for(Path("/x/podman-mac-helper"))
|
||||
assert spec is not None
|
||||
self.assertEqual(spec.identifier_suffix, "limactl")
|
||||
self.assertEqual(spec.identifier_suffix, "podman_mac_helper")
|
||||
self.assertIsNone(macos_sign_spec_for(Path("/x/not_a_known_binary")))
|
||||
|
||||
def test_matches_lima_bundle_layout(self):
|
||||
keys = set(MACOS_SERVER_BINARIES.keys())
|
||||
self.assertIn("limactl", keys)
|
||||
forbidden = {"podman", "gvproxy", "vfkit", "krunkit", "podman-mac-helper"}
|
||||
leftover = forbidden & keys
|
||||
self.assertFalse(leftover, f"podman-era entries still present: {leftover}")
|
||||
def test_matches_podman_bundle_layout(self):
|
||||
required = {"podman", "gvproxy", "vfkit", "krunkit", "podman-mac-helper"}
|
||||
self.assertTrue(required.issubset(MACOS_SERVER_BINARIES.keys()))
|
||||
|
||||
|
||||
class WindowsServerBinariesTest(unittest.TestCase):
|
||||
@@ -61,17 +58,6 @@ class WindowsServerBinariesTest(unittest.TestCase):
|
||||
for rel, abs_path in zip(WINDOWS_SERVER_BINARIES, resolved):
|
||||
self.assertEqual(abs_path, root / rel)
|
||||
|
||||
def test_windows_has_no_stale_third_party(self):
|
||||
forbidden = {
|
||||
"third_party/podman/podman.exe",
|
||||
"third_party/podman/gvproxy.exe",
|
||||
"third_party/podman/win-sshproxy.exe",
|
||||
"third_party/bun.exe",
|
||||
"third_party/rg.exe",
|
||||
}
|
||||
leftover = forbidden & set(WINDOWS_SERVER_BINARIES)
|
||||
self.assertFalse(leftover, f"stale entries still present: {leftover}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
2
packages/browseros/build/modules/ota/common.py
generated
2
packages/browseros/build/modules/ota/common.py
generated
@@ -239,7 +239,7 @@ def create_server_bundle_zip(resources_dir: Path, output_zip: Path) -> bool:
|
||||
"""Zip an extracted ``resources/`` tree into a Sparkle payload.
|
||||
|
||||
Produces entries like ``resources/bin/browseros_server``,
|
||||
``resources/bin/third_party/lima/limactl`` — mirroring what the agent
|
||||
``resources/bin/third_party/podman/podman`` — mirroring what the agent
|
||||
build staged and what the Chromium build bakes into the installed app.
|
||||
File modes are preserved by ``ZipFile.write`` so executable bits survive.
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.hypervisor</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.virtualization</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in New Issue
Block a user