chore: import upstream snapshot with attribution
Deploy Docs Pages / build (push) Waiting to run
Deploy Docs Pages / deploy (push) Blocked by required conditions
Real E2E Tests / Real E2E CI (push) Blocked by required conditions
SDK Tests / SDK CI (push) Blocked by required conditions
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Real E2E Tests / Python E2E (docker bridge) (push) Waiting to run
Real E2E Tests / C# E2E (docker bridge) (push) Waiting to run
Real E2E Tests / Go E2E (docker bridge) (push) Waiting to run
Real E2E Tests / Java E2E (docker bridge) (push) Waiting to run
Real E2E Tests / JavaScript E2E (docker bridge) (push) Waiting to run
SDK Tests / Python SDK Tests (sandbox) (push) Waiting to run
SDK Tests / CLI Quality (push) Waiting to run
SDK Tests / CLI Tests (push) Waiting to run
SDK Tests / Python SDK Quality (code-interpreter) (push) Waiting to run
SDK Tests / Python SDK Quality (sandbox) (push) Waiting to run
SDK Tests / Python SDK Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Go SDK Quality And Tests (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:33 +08:00
commit e0e362d700
1949 changed files with 375388 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# CODEOWNERS for OpenSandbox
# Rules are evaluated top-to-bottom; the last matching pattern wins.
# Default owners (fallback for files not matched by specific rules)
* @jwx0925 @hittyt @Pangjiping @ninan-nn
# Control plane (server)
/server/ @Pangjiping @hittyt @jwx0925 @Generalwin @ninan-nn
# Runtime agent (execd) and sandbox images
/components/execd/ @Pangjiping @hittyt @ninan-nn
/components/ingress/ @Pangjiping @hittyt @Generalwin @Spground
/components/egress/ @Pangjiping @hittyt @jwx0925
/sandboxes/ @Pangjiping @ninan-nn @jwx0925 @hittyt
# Kubernetes controller
/kubernetes/ @Spground @Generalwin @fengcone @kevinlynx @ninan-nn @hittyt @Pangjiping
# SDKs
/sdks/ @ninan-nn @jwx0925 @hittyt @Pangjiping
# Specs and docs
/specs/ @jwx0925 @hittyt @ninan-nn
# OpenSandbox Enhancement Proposals
/oseps/ @Spground @Generalwin @fengcone @kevinlynx @Pangjiping @ninan-nn @jwx0925 @hittyt
+19
View File
@@ -0,0 +1,19 @@
---
name: Feature Request
about: Suggest an idea for OpenSandbox
title: ''
labels: ''
assignees: ''
---
## Why do you need it?
Is your feature request related to a problem? Please describe in details
## How could it be?
A clear and concise description of what you want to happen. You can explain more about input of the feature, and output of it.
## Other related information
Add any other context or screenshots about the feature request here.
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Any Questions or Suggestions?
url: https://github.com/opensandbox-group/OpenSandbox/issues
about: Please ask and answer questions here.
+19
View File
@@ -0,0 +1,19 @@
# Summary
- What is changing and why?
# Testing
- [ ] Not run (explain why)
- [ ] Unit tests
- [ ] Integration tests
- [ ] e2e / manual verification
# Breaking Changes
- [ ] None
- [ ] Yes (describe impact and migration path)
# Checklist
- [ ] Linked Issue or clearly described motivation
- [ ] Added/updated docs (if needed)
- [ ] Added/updated tests (if needed)
- [ ] Security impact considered
- [ ] Backward compatibility considered
+68
View File
@@ -0,0 +1,68 @@
name: Deploy Docs Pages
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
environment:
name: github-pages
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Pages
id: pages
uses: actions/configure-pages@v5
- name: Setup Node 22
uses: actions/setup-node@v6
with:
node-version: "22"
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.0
- name: Enable corepack
run: corepack enable
- name: Install docs dependencies
working-directory: docs
run: pnpm install --frozen-lockfile
- name: Build docs
working-directory: docs
env:
DOCS_BASE: ${{ hashFiles('docs/public/CNAME') != '' && '/' || steps.pages.outputs.base_path }}
run: pnpm docs:build
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: docs/.vitepress/dist
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+100
View File
@@ -0,0 +1,100 @@
name: Detect Relevant Changes
on:
workflow_call:
inputs:
area:
description: Test area whose existing path scope should be evaluated
required: true
type: string
outputs:
relevant:
description: Whether the caller's tests are relevant to this event
value: ${{ jobs.detect.outputs.relevant }}
permissions:
contents: read
pull-requests: read
jobs:
detect:
runs-on: ubuntu-latest
outputs:
relevant: ${{ steps.changes.outputs.relevant }}
steps:
- name: Detect relevant changes
id: changes
uses: actions/github-script@v7
env:
AREA: ${{ inputs.area }}
with:
script: |
if (context.eventName !== "pull_request") {
core.info(`Running ${process.env.AREA} tests for ${context.eventName}.`);
core.setOutput("relevant", "true");
return;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
per_page: 100,
});
const paths = files.flatMap((file) =>
[file.filename, file.previous_filename].filter(Boolean),
);
const workflowChanged = (path, caller) =>
path === ".github/workflows/detect-changes.yml" ||
path === `.github/workflows/${caller}`;
const matchers = {
server: (path) =>
workflowChanged(path, "server-test.yml") ||
path.startsWith("server/"),
kubernetes: (path) =>
workflowChanged(path, "kubernetes-test.yml") ||
(path.startsWith("kubernetes/") &&
!path.startsWith("kubernetes/docs/")),
"real-e2e": (path) =>
workflowChanged(path, "real-e2e.yml") ||
path.startsWith("server/opensandbox_server/") ||
path.startsWith("components/execd/") ||
path.startsWith("components/egress/") ||
path.startsWith("sdks/") ||
path.startsWith("tests/"),
egress: (path) =>
workflowChanged(path, "egress-test.yaml.yml") ||
path.startsWith("components/egress/") ||
path.startsWith("components/internal/"),
"kubernetes-mini-e2e": (path) =>
workflowChanged(path, "kubernetes-nightly-build.yml") ||
path === "scripts/python-k8s-e2e.sh" ||
path === "scripts/python-k8s-e2e-ingress.sh" ||
path === "scripts/common/kubernetes-e2e.sh" ||
path.startsWith("kubernetes/charts/"),
ingress: (path) =>
workflowChanged(path, "ingress-test.yaml") ||
path.startsWith("components/ingress/") ||
path.startsWith("components/internal/"),
execd: (path) =>
workflowChanged(path, "execd-test.yml") ||
path.startsWith("components/execd/") ||
path.startsWith("components/internal/"),
sdk: (path) =>
workflowChanged(path, "sdk-tests.yml") ||
path.startsWith("cli/") ||
path.startsWith("sdks/") ||
path.startsWith("specs/"),
};
const area = process.env.AREA;
if (!Object.hasOwn(matchers, area)) {
core.setFailed(`Unknown test area: ${area}`);
return;
}
const relevant = paths.some(matchers[area]);
core.info(`${area}: relevant=${relevant}; files=${files.length}`);
core.setOutput("relevant", String(relevant));
+135
View File
@@ -0,0 +1,135 @@
name: Egress Tests
on:
pull_request:
branches: [ main ]
permissions:
contents: read
pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
changes:
uses: ./.github/workflows/detect-changes.yml
with:
area: egress
test:
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.25.9'
- name: Check gofmt
working-directory: components/egress
run: |
files="$(gofmt -l . ../internal)"
if [ -n "$files" ]; then
echo "$files"
exit 1
fi
- name: Run Build
working-directory: components/egress
run: |
go vet ./...
go build .
- name: Run tests
working-directory: components/egress
run: |
go test ./...
smoke:
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: self-hosted
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Clean Docker runner cache
run: bash scripts/ci-docker-cleanup.sh
- name: Run dns test
working-directory: components/egress
run: |
chmod +x tests/smoke-dns.sh
./tests/smoke-dns.sh
- name: Run nft test
working-directory: components/egress
run: |
chmod +x tests/smoke-nft.sh
./tests/smoke-nft.sh
- name: Run dynamic ip test
working-directory: components/egress
run: |
chmod +x tests/smoke-dynamic-ip.sh
./tests/smoke-dynamic-ip.sh
- name: Run dns upstream probe test
working-directory: components/egress
run: |
chmod +x tests/smoke-dns-upstream-probe.sh
./tests/smoke-dns-upstream-probe.sh
bench:
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Run bench test
working-directory: components/egress
run: |
chmod +x tests/bench-dns-nft.sh
./tests/bench-dns-nft.sh
env:
BENCH_SAMPLE_SIZE: "20"
- name: Upload egress logs
if: always()
uses: actions/upload-artifact@v7
with:
name: egress-log-for-bench
path: /tmp/egress-logs/
retention-days: 5
required:
name: Egress CI
if: always()
needs: [changes, test, smoke, bench]
runs-on: ubuntu-latest
steps:
- name: Verify required jobs
env:
RELEVANT: ${{ needs.changes.outputs.relevant }}
CHANGES_RESULT: ${{ needs.changes.result }}
TEST_RESULT: ${{ needs.test.result }}
SMOKE_RESULT: ${{ needs.smoke.result }}
BENCH_RESULT: ${{ needs.bench.result }}
run: |
if [[ "$CHANGES_RESULT" != "success" ]]; then
echo "Change detection failed: $CHANGES_RESULT"
exit 1
fi
if [[ "$RELEVANT" == "true" ]]; then
[[ "$TEST_RESULT" == "success" && "$SMOKE_RESULT" == "success" && "$BENCH_RESULT" == "success" ]]
else
[[ "$RELEVANT" == "false" && "$TEST_RESULT" == "skipped" && "$SMOKE_RESULT" == "skipped" && "$BENCH_RESULT" == "skipped" ]]
fi
+190
View File
@@ -0,0 +1,190 @@
name: Execd Tests
on:
pull_request:
branches: [ main ]
permissions:
contents: read
pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
changes:
uses: ./.github/workflows/detect-changes.yml
with:
area: execd
test:
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.25.9'
- name: Check gofmt
working-directory: components/execd
run: |
files="$(gofmt -l . ../internal)"
if [ -n "$files" ]; then
echo "$files"
exit 1
fi
- name: Run golint
working-directory: components/execd
run: |
make golint
- name: Build (Multi platform compile)
working-directory: components/execd
run: |
make multi-build
- name: Run tests with coverage
working-directory: components/execd
run: |
go test -v -coverpkg=./... -coverprofile=coverage.out -covermode=atomic ./pkg/...
- name: Calculate coverage and generate summary
working-directory: components/execd
id: coverage
run: |
# Extract total coverage percentage
TOTAL_COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}')
echo "total_coverage=$TOTAL_COVERAGE" >> $GITHUB_OUTPUT
# Generate GitHub Actions job summary
echo "## 📊 execd Test Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total Line Coverage:** $TOTAL_COVERAGE" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Coverage report generated for commit \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "---" >> $GITHUB_STEP_SUMMARY
echo "*Coverage targets: Core packages >80%, API layer >70%*" >> $GITHUB_STEP_SUMMARY
smoke:
needs: changes
if: needs.changes.outputs.relevant == 'true'
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.25.9'
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Install make (Windows)
if: matrix.os == 'windows-latest'
shell: powershell
run: choco install make -y
- name: Build
working-directory: components/execd
run: |
make build
- name: Run smoke test
working-directory: components/execd
run: |
chmod +x tests/smoke.sh
./tests/smoke.sh
sleep 5
python3 tests/smoke_api.py
- name: SIGTERM forward test
if: matrix.os == 'ubuntu-latest'
working-directory: components/execd
run: |
chmod +x tests/sigterm_forward.sh
./tests/sigterm_forward.sh
- name: Smoke test bwrap (Docker image build + extraction)
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
chmod +x components/execd/tests/smoke_bwrap.sh
bash components/execd/tests/smoke_bwrap.sh
- name: Show logs
if: always()
run: |
set -x
cat components/execd/startup.log || true
cat components/execd/execd.log || true
bwrap-smoke:
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.25.9'
- name: Build latest bubblewrap (v0.11.2)
run: |
sudo apt-get install -y meson ninja-build libcap-dev pkg-config
git clone --depth 1 --branch v0.11.2 https://github.com/containers/bubblewrap /tmp/bwrap
cd /tmp/bwrap
meson setup builddir -Dprefix=/usr -Dman=disabled -Dtests=false
ninja -C builddir
sudo cp builddir/bwrap /usr/local/bin/bwrap
bwrap --version
- name: Run bwrap integration tests
working-directory: components/execd
run: sudo -E env "PATH=$PATH" go test -tags="linux,bwrap" -v -count=1 -timeout=5m ./pkg/runtime/bwrap_test/
required:
name: Execd CI
if: always()
needs: [changes, test, smoke, bwrap-smoke]
runs-on: ubuntu-latest
steps:
- name: Verify required jobs
env:
RELEVANT: ${{ needs.changes.outputs.relevant }}
CHANGES_RESULT: ${{ needs.changes.result }}
TEST_RESULT: ${{ needs.test.result }}
SMOKE_RESULT: ${{ needs.smoke.result }}
BWRAP_SMOKE_RESULT: ${{ needs.bwrap-smoke.result }}
run: |
if [[ "$CHANGES_RESULT" != "success" ]]; then
echo "Change detection failed: $CHANGES_RESULT"
exit 1
fi
if [[ "$RELEVANT" == "true" ]]; then
[[ "$TEST_RESULT" == "success" && "$SMOKE_RESULT" == "success" && "$BWRAP_SMOKE_RESULT" == "success" ]]
else
[[ "$RELEVANT" == "false" && "$TEST_RESULT" == "skipped" && "$SMOKE_RESULT" == "skipped" && "$BWRAP_SMOKE_RESULT" == "skipped" ]]
fi
+80
View File
@@ -0,0 +1,80 @@
name: Ingress Tests
on:
pull_request:
branches: [ main ]
permissions:
contents: read
pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
changes:
uses: ./.github/workflows/detect-changes.yml
with:
area: ingress
test:
needs: changes
if: needs.changes.outputs.relevant == 'true'
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.25.9'
- name: Check gofmt
working-directory: components/ingress
run: |
files="$(gofmt -l . ../internal)"
if [ -n "$files" ]; then
echo "$files"
exit 1
fi
- name: Run golint
working-directory: components/ingress
run: |
make golint
- name: Run Build
working-directory: components/ingress
run: |
make build
- name: Run tests
working-directory: components/ingress
run: |
make test
required:
name: Ingress CI
if: always()
needs: [changes, test]
runs-on: ubuntu-latest
steps:
- name: Verify required jobs
env:
RELEVANT: ${{ needs.changes.outputs.relevant }}
CHANGES_RESULT: ${{ needs.changes.result }}
TEST_RESULT: ${{ needs.test.result }}
run: |
if [[ "$CHANGES_RESULT" != "success" ]]; then
echo "Change detection failed: $CHANGES_RESULT"
exit 1
fi
if [[ "$RELEVANT" == "true" ]]; then
[[ "$TEST_RESULT" == "success" ]]
else
[[ "$RELEVANT" == "false" && "$TEST_RESULT" == "skipped" ]]
fi
@@ -0,0 +1,199 @@
name: Kubernetes nightly build (Monorepo)
permissions:
contents: read
pull-requests: read
packages: write
on:
workflow_dispatch:
schedule:
- cron: "0 20 * * *"
pull_request:
branches: [ main ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
uses: ./.github/workflows/detect-changes.yml
with:
area: kubernetes-mini-e2e
k8s-mini-e2e:
name: Kubernetes mini E2E (${{ matrix.variant }})
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- variant: direct
script: scripts/python-k8s-e2e.sh
e2e_gateway_route_mode: ""
- variant: ingress-header
script: scripts/python-k8s-e2e-ingress.sh
e2e_gateway_route_mode: ""
- variant: ingress-uri
script: scripts/python-k8s-e2e-ingress.sh
e2e_gateway_route_mode: uri
env:
KIND_CLUSTER: opensandbox-e2e
KIND_K8S_VERSION: v1.30.4
KUBECONFIG_PATH: /tmp/opensandbox-kind-kubeconfig
KUBECONFIG: /tmp/opensandbox-kind-kubeconfig
OPENSANDBOX_E2E_SANDBOX_CPU: 250m
OPENSANDBOX_E2E_SANDBOX_MEMORY: 512Mi
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.24.0"
- name: Add Go bin to PATH
run: echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- name: Set up uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
- name: Set up kubectl
uses: azure/setup-kubectl@v4
- name: Set up Helm
uses: azure/setup-helm@v4
- name: Run Kubernetes runtime E2E
env:
E2E_GATEWAY_ROUTE_MODE: ${{ matrix.e2e_gateway_route_mode }}
run: bash "./${{ matrix.script }}"
- name: Dump kind diagnostics
if: always()
run: |
kubectl get pods -A -o wide || true
kubectl get batchsandboxes -A || true
kubectl get pv,pvc -A || true
kubectl describe deployment -n opensandbox-system opensandbox-controller-manager || true
kubectl describe deployment -n opensandbox-system opensandbox-server || true
kubectl get svc -n opensandbox-system opensandbox-server || true
- name: Eval in-cluster server logs
if: always()
run: |
kubectl logs -n opensandbox-system deployment/opensandbox-server || true
cat /tmp/opensandbox-server-port-forward.log || true
- name: Upload Python test artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: python-k8s-e2e-logs-${{ matrix.variant }}
path: |
/tmp/opensandbox-server-port-forward.log
/tmp/opensandbox-ingress-gateway-port-forward.log
if-no-files-found: ignore
retention-days: 5
- name: Clean up Kind cluster
if: always()
run: |
kind delete cluster --name "${KIND_CLUSTER}" || true
publish-nightly-latest:
name: Publish latest (${{ matrix.component }} nightly)
needs: k8s-mini-e2e
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- component: execd
workdir: components/execd
- component: ingress
workdir: components/ingress
- component: egress
workdir: components/egress
- component: controller
workdir: kubernetes
k8s_component: controller
- component: task-executor
workdir: kubernetes
k8s_component: task-executor
- component: server
workdir: server
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Login to ACR
uses: docker/login-action@v3
with:
registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com
username: ${{ secrets.ACR_USERNAME }}
password: ${{ secrets.ACR_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push (TAG=latest)
working-directory: ${{ matrix.workdir }}
run: |
export TAG=latest
export GHCR_REPO="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/opensandbox"
if [ -n "${{ matrix.k8s_component }}" ]; then
export COMPONENT="${{ matrix.k8s_component }}"
fi
chmod +x build.sh
./build.sh
required:
name: Kubernetes Mini E2E CI
if: ${{ always() && github.event_name == 'pull_request' }}
needs: [changes, k8s-mini-e2e]
runs-on: ubuntu-latest
steps:
- name: Verify required jobs
env:
RELEVANT: ${{ needs.changes.outputs.relevant }}
CHANGES_RESULT: ${{ needs.changes.result }}
E2E_RESULT: ${{ needs.k8s-mini-e2e.result }}
run: |
if [[ "$CHANGES_RESULT" != "success" ]]; then
echo "Change detection failed: $CHANGES_RESULT"
exit 1
fi
if [[ "$RELEVANT" == "true" ]]; then
[[ "$E2E_RESULT" == "success" ]]
else
[[ "$RELEVANT" == "false" && "$E2E_RESULT" == "skipped" ]]
fi
+130
View File
@@ -0,0 +1,130 @@
name: Sandbox Kubernetes Tests
on:
pull_request:
branches: [ main ]
permissions:
contents: read
pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
GO_VERSION: '1.24'
jobs:
changes:
uses: ./.github/workflows/detect-changes.yml
with:
area: kubernetes
controller-e2e-core:
name: Controller E2E Core (Kubernetes v${{ matrix.version }})
needs: changes
if: needs.changes.outputs.relevant == 'true'
strategy:
fail-fast: false
matrix:
version: ["1.21.1", "1.22.4", "1.24.4", "1.26.4", "1.28.6", "1.30.4", "1.32.2", "1.34.2"]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
- name: Run core e2e tests
working-directory: kubernetes
run: |
make test-e2e-core KIND_K8S_VERSION=v${{ matrix.version }}
controller-e2e-pause-resume:
name: Controller E2E PauseResume (Kubernetes v${{ matrix.version }})
needs: changes
if: needs.changes.outputs.relevant == 'true'
strategy:
fail-fast: false
matrix:
version: ["1.21.1", "1.22.4", "1.24.4", "1.26.4", "1.28.6", "1.30.4", "1.32.2", "1.34.2"]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
- name: Run pause-resume e2e tests
working-directory: kubernetes
run: |
make test-e2e-pause-resume KIND_K8S_VERSION=v${{ matrix.version }}
test:
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.24.0'
- name: Check gofmt
working-directory: kubernetes
run: |
files="$(gofmt -l .)"
if [ -n "$files" ]; then
echo "$files"
exit 1
fi
- name: Run golint
working-directory: kubernetes
run: |
make lint
- name: Build binary
working-directory: kubernetes
run: |
make build
make task-executor-build
- name: Run tests
working-directory: kubernetes
run: |
make test
required:
name: Kubernetes CI
if: always()
needs: [changes, controller-e2e-core, controller-e2e-pause-resume, test]
runs-on: ubuntu-latest
steps:
- name: Verify required jobs
env:
RELEVANT: ${{ needs.changes.outputs.relevant }}
CHANGES_RESULT: ${{ needs.changes.result }}
CORE_RESULT: ${{ needs.controller-e2e-core.result }}
PAUSE_RESUME_RESULT: ${{ needs.controller-e2e-pause-resume.result }}
TEST_RESULT: ${{ needs.test.result }}
run: |
if [[ "$CHANGES_RESULT" != "success" ]]; then
echo "Change detection failed: $CHANGES_RESULT"
exit 1
fi
if [[ "$RELEVANT" == "true" ]]; then
[[ "$CORE_RESULT" == "success" && "$PAUSE_RESUME_RESULT" == "success" && "$TEST_RESULT" == "success" ]]
else
[[ "$RELEVANT" == "false" && "$CORE_RESULT" == "skipped" && "$PAUSE_RESUME_RESULT" == "skipped" && "$TEST_RESULT" == "skipped" ]]
fi
+106
View File
@@ -0,0 +1,106 @@
name: PR Auto Label
on:
pull_request_target:
types: [opened, reopened, synchronize]
branches: [main]
permissions:
contents: read
pull-requests: write
jobs:
auto-label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const prNumber = pr.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
function inferLabels(files) {
const dirs = new Set();
for (const f of files) {
const p = f.filename.split("/");
for (let i = 1; i <= p.length; i++) {
dirs.add(p.slice(0, i).join("/"));
}
}
const s = [];
if (dirs.has("docs") || dirs.has("oseps") || dirs.has("specs") ||
dirs.has("examples")) {
s.push("documentation");
}
if (dirs.has("components/egress")) s.push("component/egress");
if (dirs.has("components/ingress")) s.push("component/ingress");
if (dirs.has("components/execd")) s.push("component/execd");
if (dirs.has("components/code-interpreter")) s.push("component/code-interpreter");
if (dirs.has("components/internal")) {
s.push("component/egress", "component/ingress");
}
if (dirs.has("kubernetes")) s.push("component/k8s");
if (dirs.has("server")) s.push("component/server");
// SDK — layout: sdks/{sandbox,code-interpreter,mcp}/{go,python,...}
if (dirs.has("sdks")) {
s.push("sdks");
const langMap = {
go: "sdk/go",
kotlin: "sdk/java",
python: "sdk/python",
javascript: "sdk/js",
csharp: "sdk/c#",
};
for (const [dir, label] of Object.entries(langMap)) {
for (const d of dirs) {
if (d.startsWith("sdks/") && d.endsWith("/" + dir)) {
s.push(label);
break;
}
}
}
}
return [...new Set(s)].sort();
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: prNumber,
});
const inferred = inferLabels(files);
if (inferred.length === 0) {
core.info("No labels inferred from changed files, skipping.");
return;
}
const existing = new Set(pr.labels.map(l => l.name));
const toAdd = inferred.filter(l => !existing.has(l));
if (toAdd.length === 0) {
core.info("All inferred labels already present.");
return;
}
const { data: repoLabels } = await github.rest.issues.listLabelsForRepo({
owner, repo,
});
const repoLabelNames = new Set(repoLabels.map(l => l.name));
const valid = toAdd.filter(l => repoLabelNames.has(l));
if (valid.length === 0) {
core.info("Inferred labels don't exist in repo, skipping.");
return;
}
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber,
labels: valid,
});
core.info(`Added labels: ${valid.join(", ")}`);
+72
View File
@@ -0,0 +1,72 @@
name: Publish CLI
on:
push:
tags:
- 'cli/v*'
permissions:
contents: read
id-token: write
attestations: write
artifact-metadata: write
jobs:
release-preflight:
uses: ./.github/workflows/release-preflight.yml
publish-pypi:
needs: release-preflight
if: startsWith(github.ref, 'refs/tags/cli/v')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
- name: Install dependencies
working-directory: cli
run: |
uv sync --frozen
- name: Run lint
working-directory: cli
run: |
uv run ruff check
- name: Run type checks
working-directory: cli
run: |
uv run pyright
- name: Run tests
working-directory: cli
run: |
uv run pytest
- name: Build package
working-directory: cli
run: |
uv build
- name: Attest built package
if: startsWith(github.ref, 'refs/tags/cli/v')
uses: actions/attest@v4
with:
subject-path: cli/dist/*
- name: Publish to PyPI
working-directory: cli
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
run: |
uv publish
+224
View File
@@ -0,0 +1,224 @@
name: Publish Components Image
permissions:
# required for bump step to push branch and create PR
contents: write
pull-requests: write
id-token: write
attestations: write
artifact-metadata: write
packages: write
on:
workflow_dispatch:
inputs:
component:
description: 'Component to build'
required: true
type: choice
options:
- execd
- code-interpreter
- ingress
- egress
- controller
- task-executor
- image-committer
default: 'execd'
image_tag:
description: 'Docker image tag'
required: true
default: 'latest'
push:
tags:
- 'docker/execd/**'
- 'docker/code-interpreter/**'
- 'docker/ingress/**'
- 'docker/egress/**'
- 'k8s/controller/**'
- 'k8s/task-executor/**'
- 'k8s/image-committer/**'
jobs:
release-preflight:
uses: ./.github/workflows/release-preflight.yml
publish:
needs: release-preflight
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Install Cosign
if: github.ref_type == 'tag'
uses: sigstore/cosign-installer@v4.1.1
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Login to ACR
uses: docker/login-action@v3
with:
registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com
username: ${{ secrets.ACR_USERNAME }}
password: ${{ secrets.ACR_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Parse tag and set variables
id: parse_tag
run: |
if [[ "${{ github.ref }}" == refs/tags/docker/* ]]; then
TAG_PATH="${{ github.ref }}"
TAG_PATH="${TAG_PATH#refs/tags/}"
COMPONENT=$(echo "$TAG_PATH" | cut -d'/' -f2)
IMAGE_TAG=$(echo "$TAG_PATH" | cut -d'/' -f3)
echo "component=$COMPONENT" >> $GITHUB_OUTPUT
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref }}" == refs/tags/k8s/* ]]; then
TAG_PATH="${{ github.ref }}"
TAG_PATH="${TAG_PATH#refs/tags/}"
COMPONENT=$(echo "$TAG_PATH" | cut -d'/' -f2)
IMAGE_TAG=$(echo "$TAG_PATH" | cut -d'/' -f3)
echo "component=$COMPONENT" >> $GITHUB_OUTPUT
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
else
echo "component=${{ inputs.component }}" >> $GITHUB_OUTPUT
echo "image_tag=${{ inputs.image_tag }}" >> $GITHUB_OUTPUT
fi
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /opt/ghc /opt/hostedtoolcache
sudo apt-get clean
sudo rm -rf /var/lib/apt/lists/*
df -h
- name: Build and push to registries
id: build
env:
BUILD_METADATA_FILE: ${{ runner.temp }}/opensandbox-component-image-metadata.json
run: |
COMPONENT="${{ steps.parse_tag.outputs.component }}"
IMAGE_TAG="${{ steps.parse_tag.outputs.image_tag }}"
if [ "$COMPONENT" == "execd" ]; then
cd components/execd
elif [ "$COMPONENT" == "ingress" ]; then
cd components/ingress
elif [ "$COMPONENT" == "egress" ]; then
cd components/egress
elif [ "$COMPONENT" == "controller" ]; then
cd kubernetes
elif [ "$COMPONENT" == "task-executor" ]; then
cd kubernetes
elif [ "$COMPONENT" == "image-committer" ]; then
cd kubernetes
else
cd sandboxes/$COMPONENT
fi
export TAG=$IMAGE_TAG
export COMPONENT=$COMPONENT
export GHCR_REPO="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/opensandbox"
chmod +x build.sh
./build.sh
DIGEST="$(jq -r '."containerimage.digest" // empty' "$BUILD_METADATA_FILE")"
if [[ -z "$DIGEST" ]]; then
echo "Unable to resolve image digest from $BUILD_METADATA_FILE" >&2
cat "$BUILD_METADATA_FILE" >&2
exit 1
fi
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
echo "dockerhub_image=docker.io/opensandbox/$COMPONENT" >> "$GITHUB_OUTPUT"
echo "acr_image=sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/$COMPONENT" >> "$GITHUB_OUTPUT"
echo "ghcr_image=$GHCR_REPO/$COMPONENT" >> "$GITHUB_OUTPUT"
- name: Sign release images
if: github.ref_type == 'tag' && steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != ''
env:
DIGEST: ${{ steps.build.outputs.digest }}
DOCKERHUB_IMAGE: ${{ steps.build.outputs.dockerhub_image }}
ACR_IMAGE: ${{ steps.build.outputs.acr_image }}
GHCR_IMAGE: ${{ steps.build.outputs.ghcr_image }}
run: |
set -euo pipefail
cosign sign --yes "${DOCKERHUB_IMAGE}@${DIGEST}" "${ACR_IMAGE}@${DIGEST}" "${GHCR_IMAGE}@${DIGEST}"
- name: Attest Docker Hub image
if: github.ref_type == 'tag' && steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != ''
uses: actions/attest@v4
with:
subject-name: ${{ steps.build.outputs.dockerhub_image }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
create-storage-record: false
- name: Attest ACR image
if: github.ref_type == 'tag' && steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != ''
uses: actions/attest@v4
with:
subject-name: ${{ steps.build.outputs.acr_image }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
create-storage-record: false
- name: Attest GHCR image
if: github.ref_type == 'tag' && steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != ''
uses: actions/attest@v4
with:
subject-name: ${{ steps.build.outputs.ghcr_image }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
create-storage-record: false
- name: Bump component version in repo
if: steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != ''
env:
GH_TOKEN: ${{ github.token }}
run: |
COMPONENT="${{ steps.parse_tag.outputs.component }}"
IMAGE_TAG="${{ steps.parse_tag.outputs.image_tag }}"
# Ensure version has 'v' prefix for bump script
if [[ "$IMAGE_TAG" =~ ^v ]]; then
VERSION="$IMAGE_TAG"
else
VERSION="v${IMAGE_TAG}"
fi
./scripts/bump-component-version.sh "$COMPONENT" "$VERSION"
BRANCH="bump/${COMPONENT}-${VERSION}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add -A
git diff --staged --quiet && echo "No changes to commit" && exit 0
git commit -m "chore: bump $COMPONENT to $VERSION"
git push origin "$BRANCH"
gh pr create \
--title "chore: bump $COMPONENT to $VERSION" \
--body "Auto-generated by Publish Components workflow after building \`$COMPONENT:$VERSION\`." \
--base "$(gh api repos/${{ github.repository }} --jq .default_branch)"
+88
View File
@@ -0,0 +1,88 @@
name: Publish C# SDKs
on:
push:
tags:
- "csharp/sandbox/v*"
- "csharp/code-interpreter/v*"
permissions:
contents: read
id-token: write
attestations: write
artifact-metadata: write
jobs:
release-preflight:
uses: ./.github/workflows/release-preflight.yml
publish:
needs: release-preflight
name: Publish (${{ matrix.sdk.name }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
sdk:
- name: sandbox
tagPrefix: sandbox
csprojPath: sdks/sandbox/csharp/src/OpenSandbox/OpenSandbox.csproj
- name: code-interpreter
tagPrefix: code-interpreter
csprojPath: sdks/code-interpreter/csharp/src/OpenSandbox.CodeInterpreter/OpenSandbox.CodeInterpreter.csproj
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.0.x"
- name: Parse package version from tag
if: startsWith(github.ref, format('refs/tags/csharp/{0}/v', matrix.sdk.tagPrefix))
shell: bash
run: |
VERSION="${GITHUB_REF_NAME#csharp/${{ matrix.sdk.tagPrefix }}/v}"
echo "PACKAGE_VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Restore
if: startsWith(github.ref, format('refs/tags/csharp/{0}/v', matrix.sdk.tagPrefix))
run: |
EXTRA_RESTORE_ARGS=""
if [ "${{ matrix.sdk.name }}" = "code-interpreter" ]; then
EXTRA_RESTORE_ARGS="-p:UseLocalOpenSandboxProjectReference=false"
fi
dotnet restore "${{ matrix.sdk.csprojPath }}" ${EXTRA_RESTORE_ARGS}
- name: Pack
if: startsWith(github.ref, format('refs/tags/csharp/{0}/v', matrix.sdk.tagPrefix))
run: |
EXTRA_PACK_ARGS=""
if [ "${{ matrix.sdk.name }}" = "code-interpreter" ]; then
EXTRA_PACK_ARGS="-p:UseLocalOpenSandboxProjectReference=false"
fi
dotnet pack "${{ matrix.sdk.csprojPath }}" \
--configuration Release \
--no-restore \
-p:PackageVersion="${PACKAGE_VERSION}" \
-p:ContinuousIntegrationBuild=true \
${EXTRA_PACK_ARGS} \
--output ./artifacts/${{ matrix.sdk.name }}
- name: Attest built package
if: startsWith(github.ref, format('refs/tags/csharp/{0}/v', matrix.sdk.tagPrefix))
uses: actions/attest@v4
with:
subject-path: ./artifacts/${{ matrix.sdk.name }}/*.nupkg
- name: Publish to NuGet
if: startsWith(github.ref, format('refs/tags/csharp/{0}/v', matrix.sdk.tagPrefix))
env:
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
run: |
dotnet nuget push "./artifacts/${{ matrix.sdk.name }}/*.nupkg" \
--api-key "$NUGET_API_KEY" \
--source "https://api.nuget.org/v3/index.json" \
--skip-duplicate
+201
View File
@@ -0,0 +1,201 @@
name: Publish Helm Chart
on:
workflow_dispatch:
inputs:
component:
description: 'Component to release'
required: true
type: choice
options:
- opensandbox-controller
- opensandbox-server
- opensandbox
default: 'opensandbox-controller'
app_version:
description: 'App version (without v prefix, e.g., 0.1.0)'
required: true
default: '0.1.0'
push:
tags:
- 'helm/**' # Format: helm/<component>/<app_version>, e.g., helm/opensandbox-controller/0.1.0
jobs:
release-preflight:
uses: ./.github/workflows/release-preflight.yml
publish:
needs: release-preflight
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
artifact-metadata: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Install Helm
uses: azure/setup-helm@v4
with:
version: 'latest'
- name: Parse tag and set variables
id: parse_tag
run: |
if [[ "${{ github.ref }}" == refs/tags/helm/* ]]; then
TAG_PATH="${{ github.ref }}"
TAG_PATH="${TAG_PATH#refs/tags/}"
COMPONENT=$(echo "$TAG_PATH" | cut -d'/' -f2)
VERSION=$(echo "$TAG_PATH" | cut -d'/' -f3)
# Remove 'v' prefix if present
VERSION=${VERSION#v}
echo "component=$COMPONENT" >> $GITHUB_OUTPUT
echo "app_version=$VERSION" >> $GITHUB_OUTPUT
echo "release_tag=$TAG_PATH" >> $GITHUB_OUTPUT
else
echo "component=${{ inputs.component }}" >> $GITHUB_OUTPUT
echo "app_version=${{ inputs.app_version }}" >> $GITHUB_OUTPUT
echo "release_tag=helm/${{ inputs.component }}/${{ inputs.app_version }}" >> $GITHUB_OUTPUT
fi
- name: Verify release tag on origin
env:
RELEASE_TAG: ${{ steps.parse_tag.outputs.release_tag }}
run: |
set -euo pipefail
local_commit="$(git rev-parse 'HEAD^{commit}')"
remote_commit="$(git ls-remote origin "refs/tags/${RELEASE_TAG}^{}" | awk 'NR == 1 { print $1 }')"
if [[ -z "$remote_commit" ]]; then
remote_commit="$(git ls-remote origin "refs/tags/${RELEASE_TAG}" | awk 'NR == 1 { print $1 }')"
fi
if [[ -z "$remote_commit" ]]; then
echo "::error::Release tag '${RELEASE_TAG}' does not exist on origin. Have an authorized release manager push the protected tag before publishing the Helm chart."
exit 1
fi
if [[ "$local_commit" != "$remote_commit" ]]; then
echo "::error::Current commit is ${local_commit}, but origin tag '${RELEASE_TAG}' resolves to ${remote_commit}. Refusing to publish the Helm chart."
exit 1
fi
echo "Verified release tag '${RELEASE_TAG}' at ${remote_commit}."
- name: Set chart path
id: chart_path
run: |
COMPONENT="${{ steps.parse_tag.outputs.component }}"
if [ "$COMPONENT" == "opensandbox-controller" ]; then
CHART_PATH="kubernetes/charts/opensandbox-controller"
elif [ "$COMPONENT" == "opensandbox-server" ]; then
CHART_PATH="kubernetes/charts/opensandbox-server"
elif [ "$COMPONENT" == "opensandbox" ]; then
CHART_PATH="kubernetes/charts/opensandbox"
else
echo "Error: Unknown component: $COMPONENT"
exit 1
fi
echo "path=$CHART_PATH" >> $GITHUB_OUTPUT
- name: Get chart version from Chart.yaml
id: chart_version
run: |
CHART_PATH="${{ steps.chart_path.outputs.path }}"
CHART_VERSION=$(grep '^version:' $CHART_PATH/Chart.yaml | awk '{print $2}')
echo "version=$CHART_VERSION" >> $GITHUB_OUTPUT
echo "Chart version: $CHART_VERSION"
- name: Update Chart.yaml with app version
run: |
APP_VERSION="${{ steps.parse_tag.outputs.app_version }}"
CHART_PATH="${{ steps.chart_path.outputs.path }}"
# Only update appVersion, keep chart version as-is in Chart.yaml
sed -i "s/^appVersion:.*/appVersion: \"$APP_VERSION\"/" $CHART_PATH/Chart.yaml
echo "Updated Chart.yaml:"
cat $CHART_PATH/Chart.yaml
- name: Build dependencies (for opensandbox all-in-one chart)
if: ${{ steps.parse_tag.outputs.component == 'opensandbox' }}
run: |
CHART_PATH="${{ steps.chart_path.outputs.path }}"
echo "Building dependencies for all-in-one chart..."
helm dependency build $CHART_PATH
- name: Lint Helm chart
run: |
CHART_PATH="${{ steps.chart_path.outputs.path }}"
helm lint $CHART_PATH
- name: Package Helm chart
run: |
CHART_PATH="${{ steps.chart_path.outputs.path }}"
helm package $CHART_PATH
- name: Attest packaged Helm chart
uses: actions/attest@v4
with:
subject-path: ${{ steps.parse_tag.outputs.component }}-*.tgz
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.parse_tag.outputs.release_tag }}
name: Helm Chart ${{ steps.parse_tag.outputs.component }} ${{ steps.chart_version.outputs.version }} (App v${{ steps.parse_tag.outputs.app_version }})
body: |
## ${{ steps.parse_tag.outputs.component }} Helm Chart
**Chart Version:** ${{ steps.chart_version.outputs.version }}
**App Version:** ${{ steps.parse_tag.outputs.app_version }}
### Installation
直接从 GitHub Release 安装:
```bash
helm install ${{ steps.parse_tag.outputs.component }} \
https://github.com/${{ github.repository }}/releases/download/${{ steps.parse_tag.outputs.release_tag }}/${{ steps.parse_tag.outputs.component }}-${{ steps.chart_version.outputs.version }}.tgz \
--namespace opensandbox-system \
--create-namespace
```
或者先下载后安装:
```bash
# 下载
wget https://github.com/${{ github.repository }}/releases/download/${{ steps.parse_tag.outputs.release_tag }}/${{ steps.parse_tag.outputs.component }}-${{ steps.chart_version.outputs.version }}.tgz
# 安装
helm install ${{ steps.parse_tag.outputs.component }} ./${{ steps.parse_tag.outputs.component }}-${{ steps.chart_version.outputs.version }}.tgz \
--namespace opensandbox-system \
--create-namespace
```
${{ steps.parse_tag.outputs.component == 'opensandbox' && '**Note**: This is an all-in-one chart that bundles controller and server. The packaged chart already includes all dependencies, no need to run `helm dependency build` when installing from release.' || '' }}
### What's Changed
- Chart version: ${{ steps.chart_version.outputs.version }}
- App version: ${{ steps.parse_tag.outputs.app_version }}
files: |
${{ steps.parse_tag.outputs.component }}-*.tgz
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+53
View File
@@ -0,0 +1,53 @@
name: Publish Java SDKs
on:
push:
tags:
- "java/sandbox/v*"
- "java/code-interpreter/v*"
permissions:
contents: read
jobs:
release-preflight:
uses: ./.github/workflows/release-preflight.yml
publish:
needs: release-preflight
name: Publish (${{ matrix.sdk.name }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
sdk:
- name: sandbox
tagPrefix: sandbox
workingDirectory: sdks/sandbox/kotlin
- name: code-interpreter
tagPrefix: code-interpreter
workingDirectory: sdks/code-interpreter/kotlin
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v5
- name: Publish to Maven Central
working-directory: ${{ matrix.sdk.workingDirectory }}
if: startsWith(github.ref, format('refs/tags/java/{0}/v', matrix.sdk.tagPrefix))
env:
ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.ORG_GRADLE_PROJECT_MAVENCENTRALUSERNAME }}
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.ORG_GRADLE_PROJECT_MAVENCENTRALPASSWORD }}
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.ORG_GRADLE_PROJECT_SIGNINGINMEMORYKEY }}
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.ORG_GRADLE_PROJECT_SIGNINGINMEMORYKEYPASSWORD }}
run: |
./gradlew publishAndReleaseToMavenCentral
+99
View File
@@ -0,0 +1,99 @@
name: Publish JavaScript SDKs
on:
push:
tags:
- "js/sandbox/v*"
- "js/code-interpreter/v*"
permissions:
contents: read
id-token: write
attestations: write
artifact-metadata: write
jobs:
release-preflight:
uses: ./.github/workflows/release-preflight.yml
publish:
needs: release-preflight
name: Publish (${{ matrix.sdk.name }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
sdk:
- name: sandbox
tagPrefix: sandbox
workingDirectory: sdks/sandbox/javascript
packageName: "@alibaba-group/opensandbox"
- name: code-interpreter
tagPrefix: code-interpreter
workingDirectory: sdks/code-interpreter/javascript
packageName: "@alibaba-group/opensandbox-code-interpreter"
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.0
run_install: false
- name: Get pnpm store path
id: pnpm-store
working-directory: sdks
run: echo "STORE_PATH=$(pnpm store path)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('sdks/pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-pnpm-
- name: Install workspace dependencies
working-directory: sdks
run: pnpm install --frozen-lockfile
- name: Build SDK
working-directory: sdks
run: pnpm --filter ${{ matrix.sdk.packageName }}... --sort run build
- name: Pack SDK
if: startsWith(github.ref, format('refs/tags/js/{0}/v', matrix.sdk.tagPrefix))
id: pack
working-directory: ${{ matrix.sdk.workingDirectory }}
run: |
set -euo pipefail
PACK_DIR="${GITHUB_WORKSPACE}/dist/npm/${{ matrix.sdk.name }}"
mkdir -p "$PACK_DIR"
pnpm pack --pack-destination "$PACK_DIR"
PACKAGE_TARBALL="$(find "$PACK_DIR" -maxdepth 1 -name '*.tgz' -print -quit)"
if [[ -z "$PACKAGE_TARBALL" ]]; then
echo "No package tarball was produced in $PACK_DIR" >&2
exit 1
fi
echo "tarball=$PACKAGE_TARBALL" >> "$GITHUB_OUTPUT"
- name: Attest packed SDK
if: startsWith(github.ref, format('refs/tags/js/{0}/v', matrix.sdk.tagPrefix))
uses: actions/attest@v4
with:
subject-path: ${{ steps.pack.outputs.tarball }}
- name: Publish to npm
if: startsWith(github.ref, format('refs/tags/js/{0}/v', matrix.sdk.tagPrefix))
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
pnpm publish "${{ steps.pack.outputs.tarball }}" --access public --no-git-checks
+131
View File
@@ -0,0 +1,131 @@
name: Publish Python SDKs
permissions:
contents: read
id-token: write
attestations: write
artifact-metadata: write
on:
push:
tags:
- "python/sandbox/v*"
- "python/code-interpreter/v*"
- "python/mcp/sandbox/v*"
jobs:
release-preflight:
uses: ./.github/workflows/release-preflight.yml
publish-sandbox:
needs: release-preflight
if: startsWith(github.ref, 'refs/tags/python/sandbox/v')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
- name: Generate API
working-directory: sdks/sandbox/python
run: |
uv run python scripts/generate_api.py
- name: Build package
working-directory: sdks/sandbox/python
run: |
uv build
- name: Attest built package
if: startsWith(github.ref, 'refs/tags/python/sandbox/v')
uses: actions/attest@v4
with:
subject-path: sdks/sandbox/python/dist/*
- name: Publish to PyPI
working-directory: sdks/sandbox/python
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
run: |
uv publish
publish-code-interpreter:
needs: release-preflight
if: startsWith(github.ref, 'refs/tags/python/code-interpreter/v')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
- name: Build package
working-directory: sdks/code-interpreter/python
run: |
uv build
- name: Attest built package
if: startsWith(github.ref, 'refs/tags/python/code-interpreter/v')
uses: actions/attest@v4
with:
subject-path: sdks/code-interpreter/python/dist/*
- name: Publish to PyPI
working-directory: sdks/code-interpreter/python
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
run: |
uv publish
publish-mcp-sandbox:
needs: release-preflight
if: startsWith(github.ref, 'refs/tags/python/mcp/sandbox/v')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
- name: Build package
working-directory: sdks/mcp/sandbox/python
run: |
uv build
- name: Attest built package
if: startsWith(github.ref, 'refs/tags/python/mcp/sandbox/v')
uses: actions/attest@v4
with:
subject-path: sdks/mcp/sandbox/python/dist/*
- name: Publish to PyPI
working-directory: sdks/mcp/sandbox/python
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
run: |
uv publish
+222
View File
@@ -0,0 +1,222 @@
name: Publish Server
on:
push:
tags:
- 'server/v*'
permissions:
contents: read
id-token: write
attestations: write
artifact-metadata: write
packages: write
jobs:
release-preflight:
uses: ./.github/workflows/release-preflight.yml
publish-pypi:
needs: release-preflight
if: startsWith(github.ref, 'refs/tags/server/v')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
- name: Build package
working-directory: server
run: |
uv build
- name: Attest built package
if: startsWith(github.ref, 'refs/tags/server/v')
uses: actions/attest@v4
with:
subject-path: server/dist/*
- name: Publish to PyPI
working-directory: server
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
run: |
uv publish
publish-image:
needs: release-preflight
if: startsWith(github.ref, 'refs/tags/server/v')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Install Cosign
if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/server/v')
uses: sigstore/cosign-installer@v4.1.1
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Login to ACR
uses: docker/login-action@v3
with:
registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com
username: ${{ secrets.ACR_USERNAME }}
password: ${{ secrets.ACR_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Parse tag and set variables
id: parse_tag
run: |
if [[ "${{ github.ref }}" == refs/tags/server/* ]]; then
TAG_PATH="${{ github.ref }}"
TAG_PATH="${TAG_PATH#refs/tags/}"
IMAGE_TAG="${TAG_PATH#server/}"
if [ -z "$IMAGE_TAG" ]; then
echo "failed to parse image tag from $TAG_PATH" >&2
exit 1
fi
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
else
echo "cannot parse tag"
exit 1
fi
- name: Build and push to registries
id: build
working-directory: server
env:
TAG: ${{ steps.parse_tag.outputs.image_tag }}
BUILD_METADATA_FILE: ${{ runner.temp }}/opensandbox-server-image-metadata.json
run: |
export GHCR_REPO="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/opensandbox"
chmod +x build.sh
./build.sh
DIGEST="$(jq -r '."containerimage.digest" // empty' "$BUILD_METADATA_FILE")"
if [[ -z "$DIGEST" ]]; then
echo "Unable to resolve image digest from $BUILD_METADATA_FILE" >&2
cat "$BUILD_METADATA_FILE" >&2
exit 1
fi
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
echo "dockerhub_image=docker.io/opensandbox/server" >> "$GITHUB_OUTPUT"
echo "acr_image=sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/server" >> "$GITHUB_OUTPUT"
echo "ghcr_image=$GHCR_REPO/server" >> "$GITHUB_OUTPUT"
- name: Sign release images
if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/server/v') && steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != ''
env:
DIGEST: ${{ steps.build.outputs.digest }}
DOCKERHUB_IMAGE: ${{ steps.build.outputs.dockerhub_image }}
ACR_IMAGE: ${{ steps.build.outputs.acr_image }}
GHCR_IMAGE: ${{ steps.build.outputs.ghcr_image }}
run: |
set -euo pipefail
cosign sign --yes "${DOCKERHUB_IMAGE}@${DIGEST}" "${ACR_IMAGE}@${DIGEST}" "${GHCR_IMAGE}@${DIGEST}"
- name: Attest Docker Hub image
if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/server/v') && steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != ''
uses: actions/attest@v4
with:
subject-name: ${{ steps.build.outputs.dockerhub_image }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
create-storage-record: false
- name: Attest ACR image
if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/server/v') && steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != ''
uses: actions/attest@v4
with:
subject-name: ${{ steps.build.outputs.acr_image }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
create-storage-record: false
- name: Attest GHCR image
if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/server/v') && steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != ''
uses: actions/attest@v4
with:
subject-name: ${{ steps.build.outputs.ghcr_image }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
create-storage-record: false
bump-server-chart:
if: startsWith(github.ref, 'refs/tags/server/v')
needs: publish-image
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Parse tag and set variables
id: parse_tag
run: |
TAG_PATH="${{ github.ref }}"
TAG_PATH="${TAG_PATH#refs/tags/}"
IMAGE_TAG="${TAG_PATH#server/}"
if [ -z "$IMAGE_TAG" ]; then
echo "failed to parse image tag from $TAG_PATH" >&2
exit 1
fi
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Bump server chart image tag
env:
GH_TOKEN: ${{ github.token }}
run: |
IMAGE_TAG="${{ steps.parse_tag.outputs.image_tag }}"
if [[ "$IMAGE_TAG" =~ ^v ]]; then
VERSION="$IMAGE_TAG"
else
VERSION="v${IMAGE_TAG}"
fi
chmod +x scripts/bump-server-chart.sh
./scripts/bump-server-chart.sh "$VERSION"
BRANCH="bump/server-chart-${VERSION}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add kubernetes/charts/opensandbox-server/values.yaml
git diff --staged --quiet && echo "No changes to commit" && exit 0
git commit -m "chore(chart): bump opensandbox-server image to ${VERSION}"
git push origin "$BRANCH"
gh pr create \
--title "chore(chart): bump opensandbox-server image to ${VERSION}" \
--body "Auto-generated after publishing server image \`${VERSION}\` (tag \`${{ github.ref_name }}\`)." \
--base "$(gh api repos/${{ github.repository }} --jq .default_branch)"
+566
View File
@@ -0,0 +1,566 @@
name: Real E2E Tests
permissions:
contents: read
pull-requests: read
on:
workflow_dispatch:
inputs:
run_code_interpreter_e2e:
description: 'Run code-interpreter E2E tests'
required: false
type: boolean
default: false
pull_request:
branches: [ main ]
push:
branches: [ main ]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
changes:
uses: ./.github/workflows/detect-changes.yml
with:
area: real-e2e
python-e2e:
name: Python E2E (docker bridge)
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: self-hosted
env:
UV_BIN: /home/admin/.local/bin
RUN_CODE_INTERPRETER_E2E: ${{ github.event_name == 'workflow_dispatch' && inputs.run_code_interpreter_e2e == true && 'true' || 'false' }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Clean Docker runner cache
run: bash scripts/ci-docker-cleanup.sh
- name: Set up uv PATH and verify
run: |
echo "${UV_BIN}" >> "$GITHUB_PATH"
export PATH="${UV_BIN}:${PATH}"
uv --version
uv run python --version
- name: Clean up previous E2E resources
run: |
docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true
docker rm -f opensandbox-e2e-credential-vault-target || true
docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true
docker rm -f opensandbox-e2e-redis || true
# Remove root-owned files from previous sandbox runs by mounting parent dir
docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true
docker image prune -f || true
- name: Build local egress image
run: docker build -t opensandbox/egress:local -f components/egress/Dockerfile .
- name: Start Redis for Python pool E2E
run: |
docker rm -f opensandbox-e2e-redis || true
docker run -d --name opensandbox-e2e-redis -p 127.0.0.1::6379 redis:7-alpine
REDIS_PORT="$(docker inspect -f '{{(index (index .NetworkSettings.Ports "6379/tcp") 0).HostPort}}' opensandbox-e2e-redis)"
echo "OPENSANDBOX_TEST_REDIS_URL=redis://127.0.0.1:${REDIS_PORT}/0" >> "$GITHUB_ENV"
for i in {1..30}; do
if docker exec opensandbox-e2e-redis redis-cli ping | grep -q PONG; then
exit 0
fi
sleep 1
done
docker logs opensandbox-e2e-redis
exit 1
- name: Run tests
run: |
set -e
# Create config file
cat <<EOF > ~/.sandbox.toml
[server]
host = "127.0.0.1"
port = 8080
api_key = ""
[log]
level = "INFO"
[runtime]
type = "docker"
execd_image = "opensandbox/execd:local"
[egress]
image = "opensandbox/egress:local"
mode = "dns+nft"
[docker]
network_mode = "bridge"
[storage]
allowed_host_paths = ["/tmp/opensandbox-e2e"]
[renew_intent]
enabled = true
min_interval_seconds = 60
EOF
./scripts/python-e2e.sh
- name: Eval server logs
if: ${{ always() }}
run: cat server/server.log
- name: Upload execd logs
if: always()
uses: actions/upload-artifact@v7
with:
name: execd-log-for-python-e2e
path: /tmp/opensandbox-e2e/logs/
retention-days: 5
- name: Clean up after E2E
if: always()
run: |
docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true
docker rm -f opensandbox-e2e-credential-vault-target || true
docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true
docker rm -f opensandbox-e2e-redis || true
docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true
java-e2e:
name: Java E2E (docker bridge)
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: self-hosted
env:
UV_BIN: /home/admin/.local/bin
RUN_CODE_INTERPRETER_E2E: ${{ github.event_name == 'workflow_dispatch' && inputs.run_code_interpreter_e2e == true && 'true' || 'false' }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Clean Docker runner cache
run: bash scripts/ci-docker-cleanup.sh
- name: Set up uv PATH and verify
run: |
echo "${UV_BIN}" >> "$GITHUB_PATH"
export PATH="${UV_BIN}:${PATH}"
uv --version
uv run python --version
- name: Set up JDK 8
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "8"
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
- name: Clean up previous E2E resources
run: |
docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true
docker rm -f opensandbox-e2e-credential-vault-target || true
docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true
docker rm -f opensandbox-e2e-redis || true
docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true
docker image prune -f || true
- name: Build local egress image
run: docker build -t opensandbox/egress:local -f components/egress/Dockerfile .
- name: Start Redis for Java pool E2E
run: |
docker rm -f opensandbox-e2e-redis || true
docker run -d --name opensandbox-e2e-redis -p 127.0.0.1::6379 redis:7-alpine
REDIS_PORT="$(docker inspect -f '{{(index (index .NetworkSettings.Ports "6379/tcp") 0).HostPort}}' opensandbox-e2e-redis)"
echo "OPENSANDBOX_TEST_REDIS_URL=redis://127.0.0.1:${REDIS_PORT}/0" >> "$GITHUB_ENV"
for i in {1..30}; do
if docker exec opensandbox-e2e-redis redis-cli ping | grep -q PONG; then
exit 0
fi
sleep 1
done
docker logs opensandbox-e2e-redis
exit 1
- name: Run tests
env:
GRADLE_USER_HOME: ${{ github.workspace }}/.gradle-user-home
OPENSANDBOX_TEST_SECURE_ACCESS_VERIFIABLE: "false"
run: |
set -e
export GRADLE_OPTS="-Dorg.gradle.java.installations.auto-detect=true -Dorg.gradle.java.installations.auto-download=false -Dorg.gradle.java.installations.paths=${JAVA_HOME_8_X64},${JAVA_HOME_17_X64}"
# Create config file
cat <<EOF > ~/.sandbox.toml
[server]
host = "127.0.0.1"
port = 8080
api_key = ""
[log]
level = "INFO"
[runtime]
type = "docker"
execd_image = "opensandbox/execd:local"
[egress]
image = "opensandbox/egress:local"
mode = "dns+nft"
[docker]
network_mode = "bridge"
[storage]
allowed_host_paths = ["/tmp/opensandbox-e2e"]
EOF
bash ./scripts/java-e2e.sh
- name: Eval server logs
if: ${{ always() }}
run: cat server/server.log
- name: Upload Test Report
if: always()
uses: actions/upload-artifact@v7
with:
name: java-test-report
path: tests/java/build/reports/tests/test/
retention-days: 5
- name: Upload execd logs
if: always()
uses: actions/upload-artifact@v7
with:
name: execd-log-for-java-e2e
path: /tmp/opensandbox-e2e/logs/
retention-days: 5
- name: Clean up after E2E
if: always()
run: |
docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true
docker rm -f opensandbox-e2e-credential-vault-target || true
docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true
docker rm -f opensandbox-e2e-redis || true
docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true
javascript-e2e:
name: JavaScript E2E (docker bridge)
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: self-hosted
env:
UV_BIN: /home/admin/.local/bin
NODE_VERSION: "20.19.0"
RUN_CODE_INTERPRETER_E2E: ${{ github.event_name == 'workflow_dispatch' && inputs.run_code_interpreter_e2e == true && 'true' || 'false' }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Clean Docker runner cache
run: bash scripts/ci-docker-cleanup.sh
- name: Set up uv PATH and verify
run: |
echo "${UV_BIN}" >> "$GITHUB_PATH"
export PATH="${UV_BIN}:${PATH}"
uv --version
uv run python --version
- name: Set up Node.js
run: |
NODE_DIR="/home/admin/.local/node-v${NODE_VERSION}-linux-x64"
if [ -x "${NODE_DIR}/bin/node" ]; then
echo "Node.js ${NODE_VERSION} already cached"
else
echo "Downloading Node.js ${NODE_VERSION}..."
mkdir -p /home/admin/.local
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \
| tar -xJ -C /home/admin/.local/
fi
echo "${NODE_DIR}/bin" >> "$GITHUB_PATH"
export PATH="${NODE_DIR}/bin:${PATH}"
node --version
npm --version
- name: Clean up previous E2E resources
run: |
docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true
docker rm -f opensandbox-e2e-credential-vault-target || true
docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true
docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true
docker image prune -f || true
- name: Build local egress image
run: docker build -t opensandbox/egress:local -f components/egress/Dockerfile .
- name: Run tests
run: |
set -e
# Create config file (match other E2E jobs)
cat <<EOF > ~/.sandbox.toml
[server]
host = "127.0.0.1"
port = 8080
api_key = ""
[log]
level = "INFO"
[runtime]
type = "docker"
execd_image = "opensandbox/execd:local"
[egress]
image = "opensandbox/egress:local"
mode = "dns+nft"
[docker]
network_mode = "bridge"
[storage]
allowed_host_paths = ["/tmp/opensandbox-e2e"]
EOF
bash ./scripts/javascript-e2e.sh
- name: Eval server logs
if: ${{ always() }}
run: cat server/server.log
- name: Upload Test Report
if: always()
uses: actions/upload-artifact@v7
with:
name: javascript-test-report
path: tests/javascript/build/test-results/junit.xml
retention-days: 5
- name: Upload execd logs
if: always()
uses: actions/upload-artifact@v7
with:
name: execd-log-for-js-e2e
path: /tmp/opensandbox-e2e/logs/
retention-days: 5
- name: Clean up after E2E
if: always()
run: |
docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true
docker rm -f opensandbox-e2e-credential-vault-target || true
docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true
docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true
csharp-e2e:
name: C# E2E (docker bridge)
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: self-hosted
env:
UV_BIN: /home/admin/.local/bin
RUN_CODE_INTERPRETER_E2E: ${{ github.event_name == 'workflow_dispatch' && inputs.run_code_interpreter_e2e == true && 'true' || 'false' }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Clean Docker runner cache
run: bash scripts/ci-docker-cleanup.sh
- name: Set up uv PATH and verify
run: |
echo "${UV_BIN}" >> "$GITHUB_PATH"
export PATH="${UV_BIN}:${PATH}"
uv --version
uv run python --version
- name: Set up .NET SDK
uses: actions/setup-dotnet@v5
env:
DOTNET_INSTALL_DIR: /home/admin/.local/dotnet
with:
dotnet-version: "10.0.x"
- name: Clean up previous E2E resources
run: |
docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true
docker rm -f opensandbox-e2e-credential-vault-target || true
docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true
docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true
docker image prune -f || true
- name: Build local egress image
run: docker build -t opensandbox/egress:local -f components/egress/Dockerfile .
- name: Run tests
run: |
set -e
cat <<EOF > ~/.sandbox.toml
[server]
host = "127.0.0.1"
port = 8080
api_key = ""
[log]
level = "INFO"
[runtime]
type = "docker"
execd_image = "opensandbox/execd:local"
[egress]
image = "opensandbox/egress:local"
mode = "dns+nft"
[docker]
network_mode = "bridge"
[storage]
allowed_host_paths = ["/tmp/opensandbox-e2e"]
EOF
bash ./scripts/csharp-e2e.sh
- name: Eval server logs
if: ${{ always() }}
run: cat server/server.log
- name: Upload Test Report
if: always()
uses: actions/upload-artifact@v7
with:
name: csharp-test-report
path: tests/csharp/build/test-results/
retention-days: 5
- name: Upload execd logs
if: always()
uses: actions/upload-artifact@v7
with:
name: execd-log-for-csharp-e2e
path: /tmp/opensandbox-e2e/logs/
retention-days: 5
- name: Clean up after E2E
if: always()
run: |
docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true
docker rm -f opensandbox-e2e-credential-vault-target || true
docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true
docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true
go-e2e:
name: Go E2E (docker bridge)
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: self-hosted
env:
UV_BIN: /home/admin/.local/bin
HOME: /home/admin/actions-runner
GOCACHE: /home/admin/actions-runner/_temp/go-build
GOMODCACHE: /home/admin/actions-runner/_temp/go-mod-cache
RUN_CODE_INTERPRETER_E2E: ${{ github.event_name == 'workflow_dispatch' && inputs.run_code_interpreter_e2e == true && 'true' || 'false' }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Clean Docker runner cache
run: bash scripts/ci-docker-cleanup.sh
- name: Set up uv PATH and verify
run: |
echo "${UV_BIN}" >> "$GITHUB_PATH"
export PATH="${UV_BIN}:${PATH}"
uv --version
uv run python --version
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.24"
cache: false
- name: Clean up previous E2E resources
run: |
docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true
docker rm -f opensandbox-e2e-credential-vault-target || true
docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true
docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true
docker image prune -f || true
- name: Build local egress image
run: docker build -t opensandbox/egress:local -f components/egress/Dockerfile .
- name: Run tests
run: |
set -e
cat <<EOF > ~/.sandbox.toml
[server]
host = "127.0.0.1"
port = 8080
api_key = ""
[log]
level = "INFO"
[runtime]
type = "docker"
execd_image = "opensandbox/execd:local"
[egress]
image = "opensandbox/egress:local"
mode = "dns+nft"
[docker]
network_mode = "bridge"
[storage]
allowed_host_paths = ["/tmp/opensandbox-e2e"]
EOF
bash ./scripts/go-e2e.sh
- name: Eval server logs
if: ${{ always() }}
run: cat server/server.log
- name: Upload Test Report
if: always()
uses: actions/upload-artifact@v7
with:
name: go-test-report
path: tests/go/reports/
retention-days: 5
- name: Upload execd logs
if: always()
uses: actions/upload-artifact@v7
with:
name: execd-log-for-go-e2e
path: /tmp/opensandbox-e2e/logs/
retention-days: 5
- name: Clean up after E2E
if: always()
run: |
docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true
docker rm -f opensandbox-e2e-credential-vault-target || true
docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true
docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true
required:
name: Real E2E CI
if: ${{ always() && github.event_name == 'pull_request' }}
needs: [changes, python-e2e, java-e2e, javascript-e2e, csharp-e2e, go-e2e]
runs-on: ubuntu-latest
steps:
- name: Verify required jobs
env:
RELEVANT: ${{ needs.changes.outputs.relevant }}
CHANGES_RESULT: ${{ needs.changes.result }}
PYTHON_RESULT: ${{ needs.python-e2e.result }}
JAVA_RESULT: ${{ needs.java-e2e.result }}
JAVASCRIPT_RESULT: ${{ needs.javascript-e2e.result }}
CSHARP_RESULT: ${{ needs.csharp-e2e.result }}
GO_RESULT: ${{ needs.go-e2e.result }}
run: |
if [[ "$CHANGES_RESULT" != "success" ]]; then
echo "Change detection failed: $CHANGES_RESULT"
exit 1
fi
if [[ "$RELEVANT" == "true" ]]; then
[[ "$PYTHON_RESULT" == "success" && "$JAVA_RESULT" == "success" && "$JAVASCRIPT_RESULT" == "success" && "$CSHARP_RESULT" == "success" && "$GO_RESULT" == "success" ]]
else
[[ "$RELEVANT" == "false" && "$PYTHON_RESULT" == "skipped" && "$JAVA_RESULT" == "skipped" && "$JAVASCRIPT_RESULT" == "skipped" && "$CSHARP_RESULT" == "skipped" && "$GO_RESULT" == "skipped" ]]
fi
+190
View File
@@ -0,0 +1,190 @@
name: Generic Release
on:
workflow_dispatch:
inputs:
target:
description: "Release target key"
required: true
type: choice
options:
- js/sandbox
- js/code-interpreter
- python/sandbox
- python/code-interpreter
- python/mcp/sandbox
- java/sandbox
- java/code-interpreter
- csharp/sandbox
- csharp/code-interpreter
- sdks/sandbox/go
- cli
- server
- docker/execd
- docker/code-interpreter
- docker/ingress
- docker/egress
- k8s/controller
- k8s/task-executor
- helm/opensandbox
- helm
version:
description: "Version to release (e.g. 1.0.5 or v0.3.0)"
required: true
type: string
from_tag:
description: "Optional previous tag override"
required: false
type: string
no_path_filter:
description: "Disable default target path filtering"
required: true
default: false
type: boolean
extra_paths:
description: "Optional extra paths (comma-separated)"
required: false
type: string
initial_release:
description: "Allow release without previous tag"
required: true
default: false
type: boolean
dry_run:
description: "Preview only, no side effects"
required: true
default: true
type: boolean
permissions:
contents: write
id-token: write
attestations: write
artifact-metadata: write
jobs:
release-preflight:
uses: ./.github/workflows/release-preflight.yml
with:
require_approval: ${{ inputs.dry_run == false }}
release:
needs: release-preflight
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Ensure script executable
run: chmod +x scripts/release/create-release.sh
- name: Run generic release script
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
ARGS=(
--target "${{ inputs.target }}"
--version "${{ inputs.version }}"
)
if [[ -n "${{ inputs.from_tag }}" ]]; then
ARGS+=(--from-tag "${{ inputs.from_tag }}")
fi
if [[ "${{ inputs.no_path_filter }}" == "true" ]]; then
ARGS+=(--no-path-filter)
fi
if [[ -n "${{ inputs.extra_paths }}" ]]; then
IFS=',' read -r -a EXTRA_PATHS <<< "${{ inputs.extra_paths }}"
for path in "${EXTRA_PATHS[@]}"; do
trimmed="$(echo "$path" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
if [[ -n "$trimmed" ]]; then
ARGS+=(--path "$trimmed")
fi
done
fi
if [[ "${{ inputs.initial_release }}" == "true" ]]; then
ARGS+=(--initial-release)
fi
if [[ "${{ inputs.dry_run }}" == "true" ]]; then
ARGS+=(--dry-run)
fi
scripts/release/create-release.sh "${ARGS[@]}"
- name: Verify release tag on origin
if: ${{ inputs.dry_run == false }}
env:
RELEASE_TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
local_commit="$(git rev-parse "${RELEASE_TAG}^{commit}")"
remote_commit="$(git ls-remote origin "refs/tags/${RELEASE_TAG}^{}" | awk 'NR == 1 { print $1 }')"
if [[ -z "$remote_commit" ]]; then
remote_commit="$(git ls-remote origin "refs/tags/${RELEASE_TAG}" | awk 'NR == 1 { print $1 }')"
fi
if [[ -z "$remote_commit" ]]; then
echo "::error::Release tag '${RELEASE_TAG}' does not exist on origin. Have an authorized release manager push the tag before publishing source artifacts."
exit 1
fi
if [[ "$local_commit" != "$remote_commit" ]]; then
echo "::error::Local release tag '${RELEASE_TAG}' resolves to ${local_commit}, but origin resolves to ${remote_commit}. Refusing to publish source artifacts."
exit 1
fi
- name: Create release source archive
if: ${{ inputs.dry_run == false }}
id: source_archive
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
SAFE_TAG="$(printf '%s' "$RELEASE_TAG" | tr '/:' '--')"
ARCHIVE_NAME="opensandbox-${SAFE_TAG}.tar.gz"
ARCHIVE_DIR="dist/release-source"
mkdir -p "$ARCHIVE_DIR"
git archive \
--format=tar.gz \
--prefix="opensandbox-${SAFE_TAG}/" \
-o "${ARCHIVE_DIR}/${ARCHIVE_NAME}" \
"$RELEASE_TAG"
(
cd "$ARCHIVE_DIR"
sha256sum "$ARCHIVE_NAME" > SHA256SUMS
)
echo "archive_path=${ARCHIVE_DIR}/${ARCHIVE_NAME}" >> "$GITHUB_OUTPUT"
echo "checksums_path=${ARCHIVE_DIR}/SHA256SUMS" >> "$GITHUB_OUTPUT"
- name: Attest source release artifacts
if: ${{ inputs.dry_run == false }}
uses: actions/attest@v4
with:
subject-path: |
${{ steps.source_archive.outputs.archive_path }}
${{ steps.source_archive.outputs.checksums_path }}
- name: Upload source release artifacts
if: ${{ inputs.dry_run == false }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
gh release upload "$RELEASE_TAG" \
"${{ steps.source_archive.outputs.archive_path }}" \
"${{ steps.source_archive.outputs.checksums_path }}" \
--clobber
+36
View File
@@ -0,0 +1,36 @@
name: Release Preflight
on:
workflow_call:
inputs:
require_approval:
description: Require approval through the release environment
required: false
default: true
type: boolean
permissions:
contents: read
jobs:
verify-source:
name: Verify release source
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Verify release commit is reachable from main
run: scripts/release/verify-release-ref.sh
approve-release:
name: Approve release
if: ${{ inputs.require_approval }}
needs: verify-source
runs-on: ubuntu-latest
environment: release
steps:
- name: Record approval
run: echo "Release environment approval granted."
+25
View File
@@ -0,0 +1,25 @@
name: Runners prune
permissions:
contents: read
on:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
system-prune:
name: Runner system prune
runs-on: self-hosted
strategy:
fail-fast: false
matrix:
runner: [1, 2, 3, 4, 5, 6, 7, 8]
steps:
- name: Docker system prune
run: |
docker system prune -a -f
+461
View File
@@ -0,0 +1,461 @@
# Copyright 2025 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: SDK Tests
on:
pull_request:
branches: [main]
push:
branches: [main]
paths:
- "cli/**"
- "sdks/**"
- "specs/**"
permissions:
contents: read
pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
changes:
uses: ./.github/workflows/detect-changes.yml
with:
area: sdk
cli-quality:
name: CLI Quality
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
- name: Install dependencies
working-directory: cli
run: |
uv sync
- name: Run ruff
working-directory: cli
run: |
uv run ruff check
- name: Run pyright
working-directory: cli
run: |
uv run pyright
cli-tests:
name: CLI Tests
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
- name: Install dependencies
working-directory: cli
run: |
uv sync
- name: Run tests
working-directory: cli
run: |
mkdir -p reports
uv run pytest tests/ -v --junitxml=reports/junit.xml
- name: Upload CLI reports
if: always()
uses: actions/upload-artifact@v4
with:
name: cli-reports
path: |
cli/reports/**
python-sdk-quality:
name: Python SDK Quality (${{ matrix.package_name }})
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- package_name: sandbox
package_dir: sdks/sandbox/python
- package_name: code-interpreter
package_dir: sdks/code-interpreter/python
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
- name: Install dependencies
working-directory: ${{ matrix.package_dir }}
run: |
uv sync
- name: Generate API
if: matrix.package_name == 'sandbox'
working-directory: sdks/sandbox/python
run: |
uv run python scripts/generate_api.py
- name: Run ruff
working-directory: ${{ matrix.package_dir }}
run: |
uv run ruff check
- name: Run pyright
working-directory: ${{ matrix.package_dir }}
run: |
uv run pyright
python-sdk-tests:
name: Python SDK Tests (${{ matrix.package_name }})
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- package_name: sandbox
package_dir: sdks/sandbox/python
coverage_target: src/opensandbox
- package_name: code-interpreter
package_dir: sdks/code-interpreter/python
coverage_target: src/code_interpreter
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
- name: Install dependencies
working-directory: ${{ matrix.package_dir }}
run: |
uv sync
- name: Generate API
if: matrix.package_name == 'sandbox'
working-directory: sdks/sandbox/python
run: |
uv run python scripts/generate_api.py
- name: Run tests
working-directory: ${{ matrix.package_dir }}
run: |
mkdir -p reports
uv run pytest tests/ -v \
--junitxml=reports/junit.xml \
--cov=${{ matrix.coverage_target }} \
--cov-report=term-missing \
--cov-report=xml:reports/coverage.xml
- name: Upload Python reports
if: always()
uses: actions/upload-artifact@v4
with:
name: python-${{ matrix.package_name }}-reports
path: |
${{ matrix.package_dir }}/reports/**
javascript-sdk-quality:
name: JavaScript SDK Quality And Tests (${{ matrix.package_name }})
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- package_name: sandbox
package_dir: sdks/sandbox/javascript
- package_name: code-interpreter
package_dir: sdks/code-interpreter/javascript
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.0
run_install: false
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
cache-dependency-path: sdks/pnpm-lock.yaml
- name: Install dependencies
working-directory: sdks
run: |
pnpm install --frozen-lockfile
- name: Build JavaScript SDK dependencies
working-directory: sdks
run: |
pnpm run build:js
- name: Run eslint
working-directory: ${{ matrix.package_dir }}
run: |
pnpm run lint
- name: Run TypeScript typecheck
working-directory: ${{ matrix.package_dir }}
run: |
pnpm run typecheck
- name: Run tests
working-directory: ${{ matrix.package_dir }}
run: |
mkdir -p reports
node --test tests/*.test.mjs > reports/test-output.txt
- name: Upload JavaScript reports
if: always()
uses: actions/upload-artifact@v4
with:
name: javascript-${{ matrix.package_name }}-reports
path: |
${{ matrix.package_dir }}/reports/**
kotlin-sdk-quality:
name: Kotlin SDK Quality And Tests (${{ matrix.package_name }})
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- package_name: sandbox
package_dir: sdks/sandbox/kotlin
test_task: :sandbox:test
gradle_args: ""
- package_name: code-interpreter
package_dir: sdks/code-interpreter/kotlin
test_task: :code-interpreter:test
gradle_args: "-PuseMavenLocal"
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v5
- name: Publish sandbox SDK to Maven local
if: matrix.package_name == 'code-interpreter'
working-directory: sdks/sandbox/kotlin
run: |
./gradlew publishToMavenLocal
- name: Run quality checks and tests
working-directory: ${{ matrix.package_dir }}
run: |
./gradlew spotlessCheck ${{ matrix.test_task }} ${{ matrix.gradle_args }}
- name: Upload Kotlin reports
if: always()
uses: actions/upload-artifact@v4
with:
name: kotlin-${{ matrix.package_name }}-reports
path: |
${{ matrix.package_dir }}/**/build/test-results/test/**
${{ matrix.package_dir }}/**/build/reports/tests/test/**
csharp-sdk-quality:
name: C# SDK Quality And Tests (${{ matrix.package_name }})
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- package_name: sandbox
package_dir: sdks/sandbox/csharp
solution: OpenSandbox.sln
test_project: tests/OpenSandbox.Tests/OpenSandbox.Tests.csproj
- package_name: code-interpreter
package_dir: sdks/code-interpreter/csharp
solution: OpenSandbox.CodeInterpreter.sln
test_project: tests/OpenSandbox.CodeInterpreter.Tests/OpenSandbox.CodeInterpreter.Tests.csproj
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up .NET 10
uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.0.x"
- name: Build with analyzers
working-directory: ${{ matrix.package_dir }}
run: |
dotnet build ${{ matrix.solution }} --configuration Release /warnaserror
- name: Run tests with reports
working-directory: ${{ matrix.package_dir }}
run: |
dotnet test ${{ matrix.test_project }} \
--configuration Release \
--no-build \
--logger "trx;LogFileName=test-results.trx" \
--results-directory TestResults \
--collect:"XPlat Code Coverage"
- name: Upload C# reports
if: always()
uses: actions/upload-artifact@v4
with:
name: csharp-${{ matrix.package_name }}-reports
path: |
${{ matrix.package_dir }}/TestResults/**
go-sdk-quality:
name: Go SDK Quality And Tests
needs: changes
if: needs.changes.outputs.relevant == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.20"
- name: Check gofmt
working-directory: sdks/sandbox/go
run: |
files="$(gofmt -l .)"
if [ -n "$files" ]; then
echo "$files"
exit 1
fi
- name: Run go vet
working-directory: sdks/sandbox/go
run: |
go vet ./...
- name: Run tests with race detector
working-directory: sdks/sandbox/go
run: |
mkdir -p reports
go test -v -race -coverprofile=reports/coverage.out ./...
go tool cover -func=reports/coverage.out
- name: Upload Go reports
if: always()
uses: actions/upload-artifact@v4
with:
name: go-sdk-reports
path: |
sdks/sandbox/go/reports/**
required:
name: SDK CI
if: ${{ always() && github.event_name == 'pull_request' }}
needs:
- changes
- cli-quality
- cli-tests
- python-sdk-quality
- python-sdk-tests
- javascript-sdk-quality
- kotlin-sdk-quality
- csharp-sdk-quality
- go-sdk-quality
runs-on: ubuntu-latest
steps:
- name: Verify required jobs
env:
RELEVANT: ${{ needs.changes.outputs.relevant }}
CHANGES_RESULT: ${{ needs.changes.result }}
CLI_QUALITY_RESULT: ${{ needs.cli-quality.result }}
CLI_TESTS_RESULT: ${{ needs.cli-tests.result }}
PYTHON_QUALITY_RESULT: ${{ needs.python-sdk-quality.result }}
PYTHON_TESTS_RESULT: ${{ needs.python-sdk-tests.result }}
JAVASCRIPT_RESULT: ${{ needs.javascript-sdk-quality.result }}
KOTLIN_RESULT: ${{ needs.kotlin-sdk-quality.result }}
CSHARP_RESULT: ${{ needs.csharp-sdk-quality.result }}
GO_RESULT: ${{ needs.go-sdk-quality.result }}
run: |
if [[ "$CHANGES_RESULT" != "success" ]]; then
echo "Change detection failed: $CHANGES_RESULT"
exit 1
fi
if [[ "$RELEVANT" == "true" ]]; then
[[ "$CLI_QUALITY_RESULT" == "success" && "$CLI_TESTS_RESULT" == "success" && "$PYTHON_QUALITY_RESULT" == "success" && "$PYTHON_TESTS_RESULT" == "success" && "$JAVASCRIPT_RESULT" == "success" && "$KOTLIN_RESULT" == "success" && "$CSHARP_RESULT" == "success" && "$GO_RESULT" == "success" ]]
else
[[ "$RELEVANT" == "false" && "$CLI_QUALITY_RESULT" == "skipped" && "$CLI_TESTS_RESULT" == "skipped" && "$PYTHON_QUALITY_RESULT" == "skipped" && "$PYTHON_TESTS_RESULT" == "skipped" && "$JAVASCRIPT_RESULT" == "skipped" && "$KOTLIN_RESULT" == "skipped" && "$CSHARP_RESULT" == "skipped" && "$GO_RESULT" == "skipped" ]]
fi
+150
View File
@@ -0,0 +1,150 @@
name: Server Tests
on:
pull_request:
branches: [ main ]
permissions:
contents: read
pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
changes:
uses: ./.github/workflows/detect-changes.yml
with:
area: server
test:
needs: changes
if: needs.changes.outputs.relevant == 'true'
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Install uv
run: |
pip install uv
- name: Run tests
run: |
cd server
uv sync --all-groups
uv run ruff check
mkdir -p reports
uv run pytest \
--cov=opensandbox_server \
--cov-report=term \
--cov-report=xml:reports/coverage.xml \
--cov-fail-under=80
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: server-coverage-${{ matrix.os }}
path: server/reports/coverage.xml
docker-smoke:
needs: changes
if: needs.changes.outputs.relevant == 'true'
strategy:
matrix:
network: [host, bridge]
runs-on: ubuntu-latest
env:
OPENSANDBOX_INSECURE_SERVER: YES
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Install uv
run: |
pip install uv
- name: Set up Docker
run: |
docker --version
- name: Run smoke test
run: |
set -e
cd server
uv sync --all-groups
# Create config file
cat <<EOF > ~/.sandbox.toml
[server]
host = "127.0.0.1"
port = 32888
api_key = ""
[log]
level = "INFO"
[runtime]
type = "docker"
execd_image = "opensandbox/execd:latest"
[egress]
image = "opensandbox/egress:latest"
[docker]
network_mode = "${{ matrix.network }}"
[storage]
allowed_host_paths = ["/tmp/opensandbox-e2e"]
EOF
# Start server in background
uv run python -m opensandbox_server.main > app.log 2>&1 &
# Wait for server to start
sleep 10
# Run smoke test
chmod +x tests/smoke.sh
./tests/smoke.sh
- name: Show logs
if: always()
run: |
cat server/app.log
required:
name: Server CI
if: always()
needs: [changes, test, docker-smoke]
runs-on: ubuntu-latest
steps:
- name: Verify required jobs
env:
RELEVANT: ${{ needs.changes.outputs.relevant }}
CHANGES_RESULT: ${{ needs.changes.result }}
TEST_RESULT: ${{ needs.test.result }}
DOCKER_SMOKE_RESULT: ${{ needs.docker-smoke.result }}
run: |
if [[ "$CHANGES_RESULT" != "success" ]]; then
echo "Change detection failed: $CHANGES_RESULT"
exit 1
fi
if [[ "$RELEVANT" == "true" ]]; then
[[ "$TEST_RESULT" == "success" && "$DOCKER_SMOKE_RESULT" == "success" ]]
else
[[ "$RELEVANT" == "false" && "$TEST_RESULT" == "skipped" && "$DOCKER_SMOKE_RESULT" == "skipped" ]]
fi
+26
View File
@@ -0,0 +1,26 @@
name: Verify License Headers
on:
pull_request:
branches: [ main ]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify-license:
runs-on: self-hosted
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Run license verification
run: |
chmod +x scripts/verify-license.sh
./scripts/verify-license.sh
+279
View File
@@ -0,0 +1,279 @@
# IDE and Editor files
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# Go
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
# Dependency directories
vendor/
# Go workspace file
go.work
# Java/Kotlin
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs
hs_err_pid*
replay_pid*
# Gradle
.gradle/
build/
!**/gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
# Maven
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
.mvn/wrapper/maven-wrapper.jar
# Node.js
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Dependency directories
node_modules/
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# Yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
public
!docs/public/
!docs/public/CNAME
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
venv/
env/
ENV/
env.bak/
venv.bak/
# Docker
*.pid
*.seed
*.pid.lock
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Temporary files
*.tmp
*.temp
*~
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# API keys and secrets
secrets/
*.pem
*.key
*.crt
*.p12
*.pfx
# Generated API documentation
docs/generated/
docs/.vitepress/generated/
docs/.vitepress/dist/
docs/.vitepress/cache/
apidocs/
# Test results
test-results/
coverage/
*.coverage
.nyc_output
# Backup files
*.bak
*.backup
*.old
# Flattened POM files (Maven)
.flattened-pom.xml
# Kotlin
*.kotlin_module
# JetBrains specific
.idea/
*.iml
*.ipr
*.iws
out/
# Eclipse specific
.project
.classpath
.settings/
bin/
# NetBeans specific
nbproject/
nbbuild/
nbdist/
.nb-gradle/
# Generated files
generated/
**/generated/**
# gVisor runtime binaries (downloaded dynamically)
kubernetes/test/kind/gvisor/runsc
kubernetes/test/kind/gvisor/containerd-shim-runsc-v1
bin/
obj/
.qoder/
/.vs
+28
View File
@@ -0,0 +1,28 @@
# Minimal cross-language pre-commit hooks
# Install: pip install pre-commit && pre-commit install
# Run once on all files: pre-commit run --all-files
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: mixed-line-ending
- id: check-merge-conflict
- id: check-yaml
- id: detect-private-key
# Language-specific formatters/linters can be added later, for example:
# - repo: local
# hooks:
# - id: gofmt
# name: gofmt
# entry: gofmt
# language: system
# types: [go]
# - id: ruff
# name: ruff
# entry: ruff check
# language: system
# types: [python]
+122
View File
@@ -0,0 +1,122 @@
# OpenSandbox AGENTS
Use this file as the root router for the monorepo. Prefer the nearest `AGENTS.md` in the directory tree for task-specific instructions.
## Repository Map
- `server/`: FastAPI lifecycle control plane, Docker/Kubernetes runtime integration, snapshot metadata, and server tests
- `components/execd/`: in-sandbox execution daemon
- `components/egress/`: per-sandbox network egress policy sidecar
- `components/ingress/`: ingress gateway and endpoint routing
- `components/internal/`: shared Go helpers used by runtime components
- `sdks/`: sandbox, code-interpreter, and MCP SDKs plus generated clients
- `specs/`: public OpenAPI contracts and examples
- `kubernetes/`: Kubernetes operator, CRDs, task-executor, Helm charts, and Kind e2e tests
- `cli/`: `osb` command-line client and bundled CLI skills
- `tests/`: cross-language end-to-end SDK tests
- `docs/`, `examples/`, `sandboxes/`, `oseps/`: documentation, samples, images/environments, and proposals
## Routing
- For `server/**`, or lifecycle server behavior, sandbox creation flow, or user-visible server config, read `server/AGENTS.md`.
- For `sdks/**`, or SDK generation, handwritten adapters, or cross-language SDK alignment, read `sdks/AGENTS.md`.
- For `specs/**`, or API contract, schema, or example changes, read `specs/AGENTS.md`.
- For `kubernetes/**`, or CRDs, controller behavior, task execution, Helm/Kustomize deployment, pool scheduling, pause/resume snapshots, or Kind e2e tests, read `kubernetes/AGENTS.md`.
- For cross-cutting changes spanning spec, server, and SDKs, start with `specs/AGENTS.md` and then read affected consumer guides.
- For runtime component changes under `components/**`, read the nearest `README.md` or `DEVELOPMENT.md`; keep component APIs aligned with `specs/` and SDK consumers.
- For CLI changes under `cli/**`, read `cli/README.md` and verify command help/output behavior alongside unit tests.
- For cross-language e2e tests under `tests/**`, read the language-local README and keep test assumptions aligned with current server and SDK behavior.
- For areas without a local `AGENTS.md`, use the nearest `README.md`, `DEVELOPMENT.md`, and CI workflow as the next source of truth.
## Working Principles
- Think before coding: state assumptions, surface ambiguity, and ask or push back when the request has conflicting interpretations.
- Simplicity first: implement the smallest solution that satisfies the request; avoid speculative features, one-off abstractions, and unnecessary configurability.
- Surgical changes: touch only files and lines needed for the task, match local style, and do not refactor or delete unrelated pre-existing code.
- Goal-driven execution: translate non-trivial work into verifiable success criteria, add or update focused tests when behavior changes, and loop until checks pass or blockers are clear.
## Guardrails
Always:
- Keep changes focused on the user request.
- Treat `specs/*` as public contract sources.
- Keep spec, implementation, SDKs, docs, examples, config, and CLI behavior aligned when user-visible behavior changes.
- When changing `specs/*`, also update or verify affected server, SDK, docs, and release outputs when practical.
- When changing CRDs or Kubernetes public behavior, update or verify generated manifests, Helm/Kustomize deployment output, server Kubernetes integration, and docs when practical.
- Prefer additive, backward-compatible changes for public interfaces.
- Regenerate derived outputs when the source-of-truth file changes.
- Update tests when behavior changes or bugs are fixed.
- Mention unrun or blocked verification in the final handoff.
- Prefer file-scoped or package-scoped checks before full-suite validation.
Ask first:
- Breaking public API, SDK, config, protocol, or CLI changes
- Breaking CRD, annotation, label, Helm values, or Kubernetes deployment changes
- Intentional drift between a public contract and its implementation
- User-visible config or behavior changes without a clear migration story
Never:
- Edit generated output as the only fix.
- Mix unrelated component work into the same change.
## Documentation Rules
### Content ownership — single source of truth
| Content type | Source of truth | Rule |
|---|---|---|
| User and operations docs | `docs/` | Keep long-form docs here |
| Root README | repo root `README.md` | GitHub homepage |
| SDK, CLI, Helm, and other publishable package READMEs | package directory | Keep install, quick start, and package entry points |
| Non-publishable component/module READMEs | component/module directory | Keep minimal pointers to `docs/` when a docs page exists |
| Examples | `docs/examples/` + runnable code under `examples/` | Put docs in `docs/examples/`; keep example READMEs as thin pointers |
| OSEPs | `oseps/` | `docs/community/oseps.md` only indexes GitHub proposals |
| CONTRIBUTING, CODE_OF_CONDUCT, DEVELOPMENT | repo root / component directory | `docs/community/` links to them, does not duplicate them |
**When modifying user-visible or operations-visible behavior**: update `docs/` first.
**When editing READMEs**: avoid long-form docs outside root or publishable package READMEs.
**When adding examples**: keep runnable code under `examples/`, and document it under `docs/examples/`.
**When linking from README files to docs**: prefer repository-relative `docs/*.md` links so links match the checked-out branch or tag. Use `https://open-sandbox.ai` as a public site entry point, not as the only source link from versioned code directories.
**When handling localized READMEs**: do not add new localized copies for SDKs, examples, or non-special modules.
### Docs site structure
```
docs/
getting-started/ # Quick start, installation, configuration
architecture/ # Architecture overview, network design
guides/ # Feature guides (credential vault, secure container, etc.)
sdks/ # SDK reference (one page per language per SDK)
components/ # Server, execd, ingress, egress
kubernetes/ # Kubernetes operator and deployment
api/ # OpenAPI spec reference
cli/ # CLI reference
examples/ # One page per example
community/ # Contributing, code of conduct, OSEPs, releases
reference/ # Migration guides
```
### Docs conventions
- Engine: VitePress. Config is a static `config.mts` with no build-time code generation.
- All images go in `docs/public/images/`, referenced as `/images/filename` in markdown.
- Every page must have YAML frontmatter with `title` and `description`.
- Internal links use VitePress absolute paths (e.g., `/sdks/python`, `/guides/credential-vault`).
- Links to source code or specs use full GitHub URLs.
- Use VitePress custom containers (`::: tip`, `::: warning`, `::: info`) and code groups where appropriate.
- Build and verify: `cd docs && pnpm docs:build` — must complete with zero errors.
- The README.md in `docs/` is for docs-site contributors only (how to run dev server), and must stay excluded from the published site.
## Review Focus
- Prioritize breaking changes in specs, SDK interfaces, config, CLI behavior, and protocols.
- Flag protocol changes that are unnecessary, inconsistent, or hard to implement.
- Flag changes that break source-of-truth boundaries or intended layering.
- Call out missing tests and compatibility risks explicitly.
+3
View File
@@ -0,0 +1,3 @@
# OpenSandbox Claude Guide
See `AGENTS.md` for all rules, routing, and conventions.
+23
View File
@@ -0,0 +1,23 @@
# Code of Conduct
We are committed to a welcoming, safe, and respectful community.
## Expected Behavior
- Be respectful and inclusive.
- Assume good intent; seek to understand.
- Provide constructive feedback; critique code, not people.
- Follow project guidelines and security practices.
## Unacceptable Behavior
- Harassment, personal attacks, or discriminatory language.
- Publishing private information without consent.
- Disruptive or aggressive behavior in any project space.
## Scope
This Code applies to all project spaces, including issues, pull requests, discussions, chat, and events.
## Reporting
Report incidents to: **conduct@opensandbox.io**. Include as much detail as possible (what happened, when/where, links, screenshots if applicable).
## Enforcement
Maintainers will investigate in good faith and may take appropriate action, including warnings, temporary bans, or removal from the community.
+675
View File
@@ -0,0 +1,675 @@
# Contributing to OpenSandbox
Thank you for your interest in contributing to OpenSandbox! This guide will help you get started with contributing to the project, whether you're fixing bugs, adding features, improving documentation, or helping in other ways.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Development Environment Setup](#development-environment-setup)
- [Project Structure](#project-structure)
- [Development Workflow](#development-workflow)
- [Coding Standards](#coding-standards)
- [Testing Guidelines](#testing-guidelines)
- [Submitting Contributions](#submitting-contributions)
- [Communication Channels](#communication-channels)
## Code of Conduct
OpenSandbox adheres to a [Code of Conduct](CODE_OF_CONDUCT.md) that we expect all contributors to follow. Please read it before contributing to ensure a welcoming and inclusive environment for everyone.
## Getting Started
### Ways to Contribute
There are many ways to contribute to OpenSandbox:
- **Report Bugs**: Submit detailed bug reports through [GitHub Issues](https://github.com/opensandbox-group/OpenSandbox/issues)
- **Suggest Features**: Propose new features or improvements
- **Write Code**: Fix bugs, implement features, or improve performance
- **Improve Documentation**: Enhance README files, write tutorials, or fix typos
- **Write Tests**: Add test coverage or improve existing tests
- **Review Pull Requests**: Help review and test others' contributions
- **Answer Questions**: Help other users in GitHub Discussions or Issues
### Before You Start
1. **Search Existing Issues**: Check if your bug report or feature request already exists
2. **Check Roadmap**: Review the project roadmap to see if your idea aligns with project goals
3. **Discuss Major Changes**: For significant changes, open an issue first or submit an [OSEP](oseps/README.md) to discuss your approach
4. **Review Architecture**: Read [Architecture Overview](docs/architecture/) to understand the system design
## Development Environment Setup
### Prerequisites
Different components have different requirements:
#### For Server (Python)
- **Python 3.10+**
- **uv** - Python package manager ([installation guide](https://github.com/astral-sh/uv))
- **Docker** - For running sandboxes locally
#### For execd (Go)
- **Go 1.24+**
- **Make** - Build automation (optional)
- **Docker** - For building container images
#### For SDKs
- **Python SDK**: Python 3.10+, uv
- **Java/Kotlin SDK**: JDK 17+, Gradle
### Quick Setup
#### Server Development
```bash
# Navigate to server directory
cd server
# Install dependencies
uv sync
# Copy example configuration from the source tree
cp server/opensandbox_server/examples/example.config.toml ~/.sandbox.toml
# Edit configuration for development
# Set [log] level = "DEBUG" and [server] api_key
nano ~/.sandbox.toml
# Run server
uv run python -m opensandbox_server.main
```
See [server/DEVELOPMENT.md](server/DEVELOPMENT.md) for detailed server development guide.
#### execd Development
```bash
# Navigate to execd directory
cd components/execd
# Download dependencies
go mod download
# Build execd
go build -o bin/execd .
# Run execd (requires Jupyter Server)
./bin/execd --jupyter-host=http://localhost:8888 --port=44772
```
See [components/execd/DEVELOPMENT.md](components/execd/DEVELOPMENT.md) for detailed execd development guide.
#### SDK Development
**Python SDK:**
```bash
cd sdks/sandbox/python
uv sync
uv run pytest
```
**Java/Kotlin SDK:**
```bash
cd sdks/sandbox/kotlin
./gradlew build
./gradlew test
```
## Project Structure
```
OpenSandbox/
├── sdks/ # Multi-language SDKs
│ ├── code-interpreter/ # Code Interpreter SDK (Python, Kotlin)
│ └── sandbox/ # Sandbox base SDK (Python, Kotlin)
├── specs/ # OpenAPI specifications
│ ├── execd-api.yaml # Execution API spec
│ └── sandbox-lifecycle.yml # Lifecycle API spec
├── server/ # Sandbox server (Python/FastAPI)
├── components/
│ └── execd/ # Execution daemon (Go/Beego)
├── sandboxes/ # Sandbox implementations
│ └── code-interpreter/ # Code Interpreter sandbox
├── examples/ # Example integrations
├── docs/ # Documentation
├── tests/ # Cross-component tests
│ └── e2e/ # End-to-end tests
└── scripts/ # Build and utility scripts
```
## Development Workflow
### Enhancement Proposals (OSEP)
For major features, architectural changes, or modifications to the core API/security model, we follow the **OSEP (OpenSandbox Enhancement Proposals)** process.
Please read the [OSEP README](oseps/README.md) to understand when an OSEP is required and how to submit one. Small bug fixes and minor improvements do not require an OSEP.
### Branching Strategy
- **main**: Stable production branch
- **feature/[name]**: New features
- **fix/[name]**: Bug fixes
- **docs/[name]**: Documentation updates
- **refactor/[name]**: Code refactoring
- **test/[name]**: Test additions or improvements
### Creating a Feature Branch
```bash
# Update main branch
git checkout main
git pull origin main
# Create feature branch
git checkout -b feature/my-awesome-feature
# Make your changes
# ...
# Commit your changes
git add .
git commit -m "feat: add my awesome feature"
# Push to your fork
git push origin feature/my-awesome-feature
```
### Commit Message Format
We follow [Conventional Commits](https://www.conventionalcommits.org/) specification:
```
<type>(<scope>): <description>
[optional body]
[optional footer]
```
**Types:**
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation changes
- `style`: Code style changes (formatting, no logic change)
- `refactor`: Code refactoring
- `test`: Adding or updating tests
- `chore`: Build process, dependencies, or tooling changes
- `perf`: Performance improvements
- `ci`: CI/CD changes
**Examples:**
```
feat(server): add Kubernetes runtime support
fix(execd): resolve memory leak in session cleanup
docs(sdk): add Python SDK usage examples
test(server): add integration tests for Docker runtime
refactor(sdk): simplify filesystem API
```
### Making Changes
1. **Write Clean Code**: Follow project coding standards (see below)
2. **Add Tests**: Ensure your changes are covered by tests
3. **Update Documentation**: Update relevant documentation files
4. **Test Locally**: Run all tests and ensure they pass
5. **Check Linting**: Run linters and fix any issues
## Coding Standards
Contributions are required to generally comply with the coding standards for
the language and component they touch. Generated files are excluded where the
local tool configuration excludes them; update the source specification or
generator and regenerate those files instead of hand-editing generated output.
### Required Style Guides
| Area | Primary language | Required style guide |
| --- | --- | --- |
| Server, CLI, Python SDKs, Python tests | Python | [PEP 8](https://peps.python.org/pep-0008/) plus Google-style docstrings for public APIs |
| Go components, Kubernetes controller, Go SDK | Go | [Effective Go](https://go.dev/doc/effective_go) and [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments) |
| JavaScript/TypeScript SDKs | JavaScript/TypeScript | [TypeScript ESLint recommended and stylistic rule sets](https://typescript-eslint.io/users/configs/) |
| Kotlin SDKs | Kotlin | [Kotlin Coding Conventions](https://kotlinlang.org/docs/coding-conventions.html) |
| C# SDKs | C# | [Microsoft C# coding conventions](https://learn.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions) |
### Automated Enforcement
| Area | Tooling and CI entry point |
| --- | --- |
| Server | `server/pyproject.toml`; `.github/workflows/server-test.yml` runs `uv run ruff check` |
| CLI and Python SDKs | package `pyproject.toml` files; `.github/workflows/sdk-tests.yml` runs `uv run ruff check` and `uv run pyright` |
| Go components and Kubernetes | `gofmt`, `go vet`, and `golangci-lint` where configured; component workflows run format, lint, build, and test checks |
| Go SDK | `.github/workflows/sdk-tests.yml` runs `gofmt`, `go vet`, and tests |
| JavaScript/TypeScript SDKs | `sdks/eslint.base.mjs` and package `eslint.config.mjs`; `.github/workflows/sdk-tests.yml` runs `pnpm run lint` and `pnpm run typecheck` |
| Kotlin SDKs | Gradle Spotless with ktlint; `.github/workflows/sdk-tests.yml` runs `./gradlew spotlessCheck ...` |
| C# SDKs | `.editorconfig`, `Directory.Build.props`, and .NET analyzers; `.github/workflows/sdk-tests.yml` runs `dotnet build ... /warnaserror` |
### Python (Server, CLI, Python SDKs)
- **Style Guide**: Follow [PEP 8](https://peps.python.org/pep-0008/)
- **Linter/Formatter**: Use `ruff` for linting and formatting
- **Type Hints**: Always use type hints for function signatures
- **Docstrings**: Use Google-style docstrings for public APIs
```python
def create_sandbox(
image: ImageSpec,
timeout: timedelta,
entrypoint: Optional[List[str]] = None
) -> Sandbox:
"""Create a new sandbox instance.
Args:
image: Container image specification
timeout: Sandbox timeout duration
entrypoint: Optional custom entrypoint command
Returns:
Created sandbox instance
Raises:
ValueError: If image or timeout is invalid
"""
# Implementation
```
**Running Linter:**
```bash
cd server
uv run ruff check
uv run ruff format opensandbox_server tests
```
### Go (components, Kubernetes, Go SDK)
- **Style Guide**: Follow [Effective Go](https://go.dev/doc/effective_go)
- **Formatter**: Use `gofmt` for formatting
- **Imports**: Organize in three groups (stdlib, third-party, internal)
- **Error Handling**: Always handle errors explicitly
```go
// Good
result, err := someOperation()
if err != nil {
logs.Error("operation failed: %v", err)
return fmt.Errorf("failed to do something: %w", err)
}
// Bad - silent failure
result, _ := someOperation()
```
**Running Formatter:**
```bash
cd components/execd
gofmt -w .
# Or
make fmt
```
### JavaScript/TypeScript (SDKs)
- **Style Guide**: Follow the TypeScript ESLint recommended and stylistic rule sets configured in `sdks/eslint.base.mjs`
- **Linter**: Use `eslint` for JavaScript/TypeScript linting
- **Type Checking**: Run `tsc` with the package `tsconfig.json`
**Running Checks:**
```bash
cd sdks
pnpm run lint:js
pnpm run typecheck:js
```
### Java/Kotlin (Java/Kotlin SDKs)
- **Style Guide**: Follow [Kotlin Coding Conventions](https://kotlinlang.org/docs/coding-conventions.html)
- **Formatter**: Use `ktlint`
- **Null Safety**: Use Kotlin's null safety features
```kotlin
suspend fun createSandbox(
image: ImageSpec,
timeout: Duration,
entrypoint: List<String>? = null
): Sandbox {
// Implementation
}
```
### C# (C# SDKs)
- **Style Guide**: Follow [Microsoft C# coding conventions](https://learn.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions)
- **Formatting**: Follow the SDK `.editorconfig` files
- **Analyzers**: Keep .NET analyzers enabled and treat build warnings as errors in CI
**Running Checks:**
```bash
cd sdks/sandbox/csharp
dotnet build OpenSandbox.sln --configuration Release /warnaserror
cd ../../code-interpreter/csharp
dotnet build OpenSandbox.CodeInterpreter.sln --configuration Release /warnaserror
```
### General Guidelines
- **Naming Conventions**:
- Functions/Methods: `snake_case` (Python), `camelCase` (Go, Kotlin)
- Classes: `PascalCase` (all languages)
- Constants: `UPPER_SNAKE_CASE` (all languages)
- Private members: `_leading_underscore` (Python), `unexported` (Go)
- **Comments**: Write clear, concise comments explaining "why", not "what"
- **Error Messages**: Provide actionable error messages with context
- **Logging**: Use appropriate log levels (DEBUG, INFO, WARNING, ERROR)
## Build System Standards
OpenSandbox produces native Go binaries for `components/execd`, `components/ingress`,
`components/egress`, `kubernetes`, and `sdks/sandbox/go`. These build systems must
preserve caller-provided build flags and only append project-required flags.
### Build Variables
- Go builds pass caller-provided `GOFLAGS` to `go build` and append project flags such as `-trimpath` and `-buildvcs=false`.
- Go linker builds pass caller-provided `LDFLAGS` to `go build -ldflags` and append project metadata flags.
- When CGO is enabled, the Go toolchain honors `CC`, `CXX`, `CGO_CFLAGS`, `CGO_CXXFLAGS`, and `CGO_LDFLAGS`. Docker-based builds also accept `CFLAGS` and `CXXFLAGS` as fallbacks for `CGO_CFLAGS` and `CGO_CXXFLAGS`.
- Docker build scripts forward these variables as build arguments when they are present in the environment.
### Debug Information
Default project builds must not strip debug information. Do not add `strip`,
`install -s`, or Go linker flags such as `-s -w` to the default build path.
Distribution-specific packaging may strip binaries only outside the default
developer and CI build path.
### Build Dependency Graph
Use package-aware build tools instead of recursive independent builds:
- Go packages are built through `go build ./...` or explicit Go package entry points.
- Kotlin projects use Gradle task dependencies.
- JavaScript and TypeScript SDKs use pnpm workspace dependencies.
- C# SDKs use solution/project references through `dotnet build`.
Subdirectory-specific Make targets may delegate to these tools, but they must not
replace the build tool's dependency graph with independent recursive directory
builds where cross-directory dependencies exist.
### Repeatable Builds
Native Go binary builds include `-trimpath`, `-buildvcs=false`, and `-ldflags`
with `-buildid= -B none` so that source paths, VCS metadata, and build IDs or
Mach-O UUIDs do not make binaries differ. For repeatable release metadata, set `SOURCE_DATE_EPOCH`
or set `BUILD_TIME`, `VERSION`, and `GIT_COMMIT` explicitly before invoking
Makefile or Docker build scripts. When `SOURCE_DATE_EPOCH` is set and
`BUILD_TIME` is unset, build scripts derive `BUILD_TIME` from
`SOURCE_DATE_EPOCH`.
## Testing Guidelines
### Test Coverage Requirements
- **Core Packages**: Aim for >80% coverage
- **API Layer**: Aim for >70% coverage
- **Utilities**: Aim for >90% coverage
### Writing Tests
#### Python Tests (pytest)
```python
import pytest
from opensandbox import Sandbox
@pytest.mark.asyncio
async def test_create_sandbox():
"""Test sandbox creation with valid parameters."""
sandbox = await Sandbox.create(
image="python:3.11",
timeout=timedelta(minutes=5)
)
assert sandbox.id is not None
assert sandbox.status == SandboxStatus.PENDING
await sandbox.kill()
@pytest.mark.asyncio
async def test_invalid_timeout():
"""Test sandbox creation fails with invalid timeout."""
with pytest.raises(ValueError):
await Sandbox.create(
image="python:3.11",
timeout=timedelta(seconds=-1)
)
```
**Running Tests:**
```bash
cd server
uv run pytest
uv run pytest --cov=src --cov-report=html
```
#### Go Tests
```go
func TestController_Execute_Python(t *testing.T) {
ctrl := NewController("http://localhost:8888", "test-token")
req := &ExecuteCodeRequest{
Language: Python,
Code: "print('hello')",
}
err := ctrl.Execute(req)
assert.NoError(t, err)
}
```
**Running Tests:**
```bash
cd components/execd
go test ./pkg/...
go test -v -cover ./pkg/...
```
#### Integration Tests
Integration tests require Docker:
```bash
# Server integration tests
cd server
uv run pytest tests/integration/
# E2E tests
cd tests/e2e/python
uv run pytest
```
### Test Best Practices
- **Test Names**: Use descriptive names that explain what is being tested
- **Arrange-Act-Assert**: Structure tests clearly
- **Isolation**: Each test should be independent
- **Mocking**: Mock external dependencies appropriately
- **Cleanup**: Always clean up resources (use fixtures, context managers)
## Submitting Contributions
### Pull Request Process
1. **Create Feature Branch**: Branch from `main`
2. **Make Changes**: Implement your feature or fix
3. **Write Tests**: Add comprehensive test coverage
4. **Update Documentation**: Update relevant docs
5. **Test Locally**: Ensure all tests pass
6. **Run Linters**: Fix any style issues
7. **Commit Changes**: Use conventional commit messages
8. **Push to Fork**: Push your branch to your fork
9. **Create Pull Request**: Submit PR with detailed description
### Pull Request Template
When creating a PR, fill out the template:
```markdown
# Summary
- What is changing and why?
# Testing
- [ ] Not run (explain why)
- [ ] Unit tests
- [ ] Integration tests
- [ ] e2e / manual verification
# Breaking Changes
- [ ] None
- [ ] Yes (describe impact and migration path)
# Checklist
- [ ] Linked Issue or clearly described motivation
- [ ] Added/updated docs (if needed)
- [ ] Added/updated tests (if needed)
- [ ] Security impact considered
- [ ] Backward compatibility considered
```
### Pull Request Guidelines
**Do:**
- Keep PRs focused and reasonably sized (< 500 lines if possible)
- Write clear PR descriptions with motivation and context
- Link related issues
- Respond to review comments promptly
- Update your PR based on feedback
- Ensure CI passes before requesting review
**Don't:**
- Mix multiple unrelated changes in one PR
- Submit PRs with failing tests
- Ignore code review feedback
- Force push after reviews have started (unless necessary)
- Include commented-out code or debug statements
### Code Review Process
1. **Automated Checks**: CI runs tests, linters, and security scans
2. **Maintainer Review**: A maintainer reviews your code
3. **Feedback Loop**: Address review comments
4. **Approval**: Once approved, a maintainer will merge your PR
5. **Cleanup**: Delete your feature branch after merge
## Communication Channels
### GitHub Issues
Use GitHub Issues for:
- Bug reports
- Feature requests
- Documentation improvements
- Questions about implementation
**Bug Report Template:**
```markdown
**Description**
A clear description of the bug.
**To Reproduce**
Steps to reproduce the behavior:
1. Create sandbox with...
2. Execute command...
3. See error
**Expected Behavior**
What you expected to happen.
**Environment**
- OpenSandbox version:
- Runtime (Docker/K8s):
- OS:
- Python/Go version:
**Additional Context**
Logs, screenshots, or other relevant information.
```
### GitHub Discussions
Use GitHub Discussions for:
- General questions
- Design discussions
- Brainstorming ideas
- Community help
### Getting Help
- **Issues**: Technical problems or bugs
- **Discussions**: Questions and community support
- **Security vulnerabilities**: Follow [SECURITY.md](SECURITY.md) and use
GitHub private vulnerability reporting. Do not open a public issue.
- **Code of Conduct reports**: Email conduct@opensandbox.io
## Additional Resources
### Documentation
- [Architecture Overview](docs/architecture/)
- [Server Development Guide](server/DEVELOPMENT.md)
- [execd Development Guide](components/execd/DEVELOPMENT.md)
- [OpenAPI Specifications](specs/README.md)
- [Python SDK Documentation](sdks/sandbox/python/README.md)
- [Java/Kotlin SDK Documentation](sdks/sandbox/kotlin/README.md)
### Examples
Browse [examples/](examples/) for real-world usage patterns:
- Code Interpreter integration
- AI Coding Agent integrations (Claude Code, Gemini CLI, etc.)
- Browser automation (Chrome, Playwright)
- Remote development (VS Code, Desktop)
### External Resources
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
- [Beego Documentation](https://beego.wiki/)
- [Jupyter Protocol](https://jupyter-client.readthedocs.io/en/stable/messaging.html)
- [OpenAPI Specification](https://swagger.io/specification/)
- [Docker API](https://docs.docker.com/engine/api/)
## Acknowledgments
Thank you for contributing to OpenSandbox! Your contributions help make this project better for everyone in the AI and developer tools community.
If you have suggestions for improving this contributing guide, please open an issue or submit a pull request.
## License
By contributing to OpenSandbox, you agree that your contributions will be licensed under the [Apache 2.0 License](LICENSE).
+286
View File
@@ -0,0 +1,286 @@
# OpenSandbox Governance
This document describes how OpenSandbox is governed today and how technical
decisions are made in the project.
It is intended to reflect the project's current open development practice. As
OpenSandbox grows, this document may evolve to support a more formal governance
model.
## Goals
OpenSandbox governance is designed to keep the project:
- open to contributors and users
- pragmatic in day-to-day decision making
- transparent in technical direction
- safe for public API, runtime, and security-sensitive changes
- sustainable across multiple components and maintainers
## Scope
This document applies to the OpenSandbox repository and its major public
surfaces, including:
- public APIs and specifications in `specs/`
- the lifecycle server in `server/`
- runtime components in `components/`
- Kubernetes controller and related assets in `kubernetes/`
- SDKs in `sdks/`
- CLI, examples, and project documentation
## Project Values
OpenSandbox maintainers and contributors are expected to act consistently with
these principles:
- **Open development**: design discussion, issue tracking, and code review
happen in public whenever possible.
- **Compatibility awareness**: public APIs, SDKs, CLI behavior, and documented
user workflows should not change casually.
- **Security first**: changes affecting isolation, networking, credentials, or
execution safety require extra scrutiny.
- **Component ownership with cross-project accountability**: subsystem
maintainers own their areas, while cross-cutting changes require broader
review.
- **Documentation and implementation alignment**: public contracts, code, SDKs,
examples, and docs should stay consistent.
## Roles
### Contributors
Contributors are anyone who participates in the project, including by:
- opening issues or discussions
- submitting pull requests
- reviewing code
- improving docs, tests, examples, or tooling
Contributors are expected to follow:
- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
- [CONTRIBUTING.md](CONTRIBUTING.md)
- [SECURITY.md](SECURITY.md) for vulnerability reporting
### Maintainers
Maintainers are contributors entrusted with reviewing and guiding changes in one
or more parts of the repository.
Today, the public record of subsystem maintainership is the
[`CODEOWNERS`](.github/CODEOWNERS) file. Code owners for a given path are the
default maintainers for that area.
Maintainer responsibilities include:
- reviewing pull requests for owned areas
- helping preserve code quality, compatibility, and security
- requesting cross-component review when a change affects other surfaces
- keeping specs, implementation, tests, docs, and examples aligned when needed
- helping contributors land changes successfully
### Project Maintainers
Project Maintainers are the maintainers responsible for repository-wide
direction and final technical decisions when a matter cannot be resolved within
normal component review.
Until a dedicated `MAINTAINERS.md` is added, the fallback `*` owners in
[`CODEOWNERS`](.github/CODEOWNERS) are treated as the public list of current
Project Maintainers.
Project Maintainers are responsible for:
- cross-cutting technical decisions
- governance updates
- maintainer onboarding and offboarding
- resolving review deadlocks
- ensuring major changes follow the appropriate design process
## Decision Making
### Day-to-Day Changes
Most changes are made through the normal pull request workflow described in
[CONTRIBUTING.md](CONTRIBUTING.md):
1. discuss the change in an issue when appropriate
2. submit a pull request
3. pass automated checks
4. receive maintainer review for affected areas
5. address feedback
6. merge once approved
Normal changes are decided by maintainer review and rough consensus.
### Component-Level Decisions
For changes limited to one subsystem, the maintainers of that subsystem are the
primary decision makers. Their review should carry the most weight for:
- implementation details
- code structure
- tests
- operational behavior within that component
If a change affects multiple subsystems, the relevant maintainers should be
involved before merge.
### Cross-Cutting and Sensitive Decisions
Changes with broader impact require wider review. This includes changes to:
- public APIs or schemas in `specs/`
- SDK interfaces or generated outputs
- CLI behavior
- lifecycle semantics
- runtime isolation or security guarantees
- ingress, egress, or authentication behavior
- release processes or repository-wide tooling
For these changes, maintainers should seek explicit review from all materially
affected areas, not just the first area touched by the patch.
### Major Design Changes and OSEPs
OpenSandbox uses the OpenSandbox Enhancement Proposal process (`OSEP`) for major
changes.
An OSEP is expected for changes that:
- introduce major features or architectural changes
- modify the core API or runtime behavior
- affect the security model or isolation guarantees
The public OSEP process is documented in:
- [oseps/README.md](oseps/README.md)
- [oseps/CONTRIBUTING.md](oseps/CONTRIBUTING.md)
When an OSEP is required, implementation should follow the approved design
direction rather than bypassing it through code review alone.
### Consensus and Voting
OpenSandbox prefers **lazy consensus** for most technical decisions:
- if affected maintainers agree, the change may proceed
- if concerns are raised, they should be addressed in the PR, issue, or OSEP
discussion
If consensus cannot be reached in a reasonable time, Project Maintainers may use
a simple majority vote of participating Project Maintainers.
Voting rules:
- each participating Project Maintainer has one vote
- maintainers should recuse themselves in case of direct conflicts of interest
- if a vote ties, the proposal does not pass and the status quo remains
## Reviews and Merge Expectations
The following expectations apply before merge:
- relevant CI checks should pass, or failures must be understood and accepted by
maintainers
- at least one maintainer of the affected area should review the change
- cross-cutting changes should be reviewed by all materially affected areas when
practical
- breaking changes should be clearly called out, with migration guidance where
needed
- docs and tests should be updated when behavior changes
Maintainers may decline or defer a change if it:
- conflicts with approved design direction
- introduces unnecessary compatibility risk
- weakens security or isolation without a strong justification
- mixes unrelated work into a single change
## Public Interfaces and Compatibility
OpenSandbox treats several surfaces as public contracts:
- API specifications in `specs/`
- published SDKs
- CLI behavior
- documented configuration and deployment behavior
Maintainers should prefer additive and backward-compatible changes when
possible.
When changing a public contract, maintainers should update or verify the
affected implementation, SDKs, docs, examples, and release outputs in the same
change when practical.
## Releases
Releases are managed through the public workflows and scripts in this
repository, including the GitHub Actions workflows under `.github/workflows/`
and the release tooling documented in `docs/community/release-automation.md`.
Maintainers responsible for a release target are expected to ensure that:
- the target has appropriate validation
- release notes accurately reflect user-visible changes
- versioning and tags follow the documented release conventions
## Communication Channels
The project's public collaboration channels are:
- GitHub Issues for bugs, feature requests, and implementation questions
- GitHub Discussions for broader design discussion and community help
- pull requests for concrete code and documentation review
- OSEPs for major design work
Security issues should follow the private reporting guidance in
[SECURITY.md](SECURITY.md).
## Becoming a Maintainer
New maintainers are selected based on sustained, high-quality contribution to
the project.
Signals that someone may be ready for maintainership include:
- repeated high-quality code or documentation contributions
- strong reviews and constructive technical feedback
- reliable follow-through on owned work
- good judgment on compatibility, security, and project direction
- collaborative behavior with contributors and maintainers
The typical process is:
1. nomination by an existing Project Maintainer
2. discussion among Project Maintainers
3. no unresolved objections after a reasonable review period, or approval by
majority vote if needed
4. update of `CODEOWNERS` and any other relevant public maintainer records
## Maintainer Inactivity and Removal
Maintainers may step down at any time by notifying the project.
Project Maintainers may also update maintainer status when someone has been
inactive for an extended period, for example several months without meaningful
review or maintenance activity.
Removal should be handled respectfully and pragmatically, with the goal of
keeping ownership accurate rather than punitive.
Maintainers may also be removed for serious violations of project expectations,
including repeated abuse of project privileges or violations of the Code of
Conduct.
## Governance Changes
Changes to this document should be made through a public pull request.
Governance changes should receive review from Project Maintainers and should not
be merged without giving maintainers and contributors a reasonable opportunity
to comment.
Substantial governance changes may be proposed through an OSEP or a dedicated
governance discussion if maintainers believe broader review is warranted.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+300
View File
@@ -0,0 +1,300 @@
<div align="center">
<img src="docs/public/images/logo.svg" alt="OpenSandbox logo" width="150" />
<h1>OpenSandbox</h1>
<p align="center">
<a href="https://trendshift.io/repositories/21828" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21828" alt="opensandbox-group%2FOpenSandbox | Trendshift" style="width: 320px; height: 70px;" width="320" height="70" /></a>
</p>
<p align="center">
<a href="https://github.com/opensandbox-group/OpenSandbox"><img src="https://img.shields.io/github/stars/opensandbox-group/OpenSandbox?style=flat-square&logo=github&logoColor=white&label=Stars&color=181717" alt="Stars" /></a>
<a href="https://www.bestpractices.dev/projects/12588"><img src="https://img.shields.io/badge/OpenSSF-Best-4C566A?style=flat-square" alt="OpenSSF Best Practices" /></a>
<a href="https://landscape.cncf.io/?item=orchestration-management--scheduling-orchestration--opensandbox"><img src="https://img.shields.io/badge/CNCF-Landscape-0C66E4?style=flat-square" alt="CNCF Landscape" /></a>
<a href="https://discord.gg/g7FuPs8YeD"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,A4Bgl5q1I1eNU/r33D18YFNrMY108aFF38V+r19RJOM=&_dt_no_comment=1&origin=11"><img src="https://img.shields.io/badge/DingTalk-Join-0089FF?style=flat-square" alt="DingTalk" /></a>
<a href="https://github.com/opensandbox-group/OpenSandbox/actions"><img src="https://img.shields.io/github/actions/workflow/status/opensandbox-group/OpenSandbox/real-e2e.yml?branch=main&label=TEST&style=flat-square&logo=github&logoColor=white" alt="E2E Status" /></a>
<a href="https://github.com/opensandbox-group/OpenSandbox/actions"><img src="https://img.shields.io/github/actions/workflow/status/opensandbox-group/OpenSandbox/kubernetes-nightly-build.yml?branch=main&label=K8S&style=flat-square&logo=kubernetes&logoColor=white" alt="Kubernetes nightly build status" /></a>
</p>
<hr />
</div>
OpenSandbox is a **general-purpose sandbox platform** for AI applications, offering multi-language SDKs, unified sandbox APIs, and Docker/Kubernetes runtimes for scenarios like Coding Agents, GUI Agents, Agent Evaluation, AI Code Execution, and RL Training.
## Features
- 🧩 **SDKs, CLI, and MCP**: Provides multi-language SDKs, the osb CLI, and MCP server integration for sandbox creation, command execution, and file operations. See [SDKs](#sdks), [CLI](#cli), and [MCP](#mcp).
- 📜 **Sandbox Protocol**: Defines sandbox lifecycle management APIs and sandbox execution APIs so you can extend custom sandbox runtimes. See [API specs](specs/README.md).
- 🚀 **Sandbox Runtime**: Built-in lifecycle management supporting Docker and high-performance Kubernetes runtime, enabling both local runs and large-scale distributed scheduling. See [Kubernetes runtime](./kubernetes).
- 🖥️ **Sandbox Environments**: Built-in Command, Filesystem, and Code Interpreter implementations. Examples cover Coding Agents (e.g., Claude Code), browser automation (Chrome, Playwright), and desktop environments (VNC, VS Code).
- 🚦 **Network Policy**: Unified ingress gateway with multiple routing strategies plus per-sandbox egress controls. See [Ingress Gateway](components/ingress) and [egress controls](components/egress).
- 🔑 **Credential Vault**: Secure credential injection for sandbox outbound requests without exposing real secrets to workloads. See [Credential Vault](docs/guides/credential-vault.md).
- 🏰 **Strong Isolation**: Supports secure container runtimes like gVisor, Kata Containers, and Firecracker microVM for enhanced isolation between sandbox workloads and the host. See [Secure Container Runtime Guide](docs/guides/secure-container.md) for details.
## SDKs
Python:
```bash
pip install opensandbox
```
Java/Kotlin (Gradle Kotlin DSL):
```kotlin
dependencies {
implementation("com.alibaba.opensandbox:sandbox:{latest_version}")
}
```
Java/Kotlin (Maven):
```xml
<dependency>
<groupId>com.alibaba.opensandbox</groupId>
<artifactId>sandbox</artifactId>
<version>{latest_version}</version>
</dependency>
```
JavaScript/TypeScript:
```bash
npm install @alibaba-group/opensandbox
```
C#/.NET:
```bash
dotnet add package Alibaba.OpenSandbox
```
Go:
```bash
go get github.com/alibaba/OpenSandbox/sdks/sandbox/go
```
## CLI
OpenSandbox also provides `osb`, a terminal CLI for the common sandbox workflow: create sandboxes, run commands, move files, inspect diagnostics, and manage runtime egress policy.
Install:
```bash
pip install opensandbox-cli
# or
uv tool install opensandbox-cli
```
Quick start:
```bash
osb config init
osb config set connection.domain localhost:8080
osb config set connection.protocol http
osb config set connection.api_key <your-api-key>
osb sandbox create --image python:3.12 --timeout 30m -o json
osb command run <sandbox-id> -o raw -- python -c "print(1 + 1)"
```
See the [CLI README](cli/README.md) for the full command reference.
## MCP
The OpenSandbox MCP server exposes sandbox creation, command execution, and text file operations to MCP-capable clients such as Claude Code and Cursor.
Install and run:
```bash
pip install opensandbox-mcp
opensandbox-mcp --domain localhost:8080 --protocol http
```
Minimal stdio config:
```json
{
"mcpServers": {
"opensandbox": {
"command": "opensandbox-mcp",
"args": ["--domain", "localhost:8080", "--protocol", "http"]
}
}
}
```
See the [MCP README](sdks/mcp/sandbox/python/README.md) for client-specific setup.
## Getting Started
Requirements:
- Docker (required for local execution)
- Python 3.10+ (required for examples and local runtime)
### Install and Configure the Sandbox Server
```bash
uvx opensandbox-server init-config ~/.sandbox.toml --example docker
uvx opensandbox-server
# Show help
# uvx opensandbox-server -h
```
### Create a Code Interpreter and Execute Commands/Codes
Install the Code Interpreter SDK
```bash
uv pip install opensandbox-code-interpreter
```
Create a sandbox and execute commands and codes.
```python
import asyncio
from datetime import timedelta
from code_interpreter import CodeInterpreter, SupportedLanguage
from opensandbox import Sandbox
from opensandbox.models import WriteEntry
async def main() -> None:
# 1. Create a sandbox
sandbox = await Sandbox.create(
"opensandbox/code-interpreter:v1.1.0",
entrypoint=["/opt/code-interpreter/code-interpreter.sh"],
env={"PYTHON_VERSION": "3.11"},
timeout=timedelta(minutes=10),
)
async with sandbox:
# 2. Execute a shell command
execution = await sandbox.commands.run("echo 'Hello OpenSandbox!'")
print(execution.logs.stdout[0].text)
# 3. Write a file
await sandbox.files.write_files([
WriteEntry(path="/tmp/hello.txt", data="Hello World", mode=644)
])
# 4. Read a file
content = await sandbox.files.read_file("/tmp/hello.txt")
print(f"Content: {content}") # Content: Hello World
# 5. Create a code interpreter
interpreter = await CodeInterpreter.create(sandbox)
# 6. Execute Python code (single-run, pass language directly)
result = await interpreter.codes.run(
"""
import sys
print(sys.version)
result = 2 + 2
result
""",
language=SupportedLanguage.PYTHON,
)
print(result.result[0].text) # 4
print(result.logs.stdout[0].text) # 3.11.14
# 7. Cleanup the sandbox
await sandbox.kill()
if __name__ == "__main__":
asyncio.run(main())
```
### More Examples
OpenSandbox provides examples covering SDK usage, agent integrations, browser automation, and training workloads. All example code is located in the `examples/` directory.
#### 🎯 Basic Examples
- **[code-interpreter](docs/examples/code-interpreter.md)** - End-to-end Code Interpreter SDK workflow in a sandbox.
- **[aio-sandbox](docs/examples/aio-sandbox.md)** - All-in-One sandbox setup using the OpenSandbox SDK.
- **[agent-sandbox](docs/examples/agent-sandbox.md)** - Example integration for running OpenSandbox workloads on Kubernetes with [kubernetes-sigs/agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox).
- **Volumes** — [Docker PVC / named volumes](docs/examples/docker-pvc-volume-mount.md), [Docker OSSFS](docs/examples/docker-ossfs-volume-mount.md), [Kubernetes PVC](docs/examples/kubernetes-pvc-volume-mount.md): persistent and shared storage patterns.
#### 🤖 Coding Agent Integrations
- **Coding CLIs** — [Claude Code](docs/examples/claude-code.md), [Gemini CLI](docs/examples/gemini-cli.md), [OpenAI Codex CLI](docs/examples/codex-cli.md), [Qwen Code](docs/examples/qwen-code.md), [Kimi CLI](docs/examples/kimi-cli.md): run each vendor CLI inside OpenSandbox.
- **[langgraph](docs/examples/langgraph.md)** - LangGraph state-machine workflow that creates/runs a sandbox job with fallback retry.
- **[google-adk](docs/examples/google-adk.md)** - Google ADK agent using OpenSandbox tools to write/read files and run commands.
- **[openclaw](docs/examples/openclaw.md)** - Launch an OpenClaw Gateway inside a sandbox.
#### 🌐 Browser and Desktop Environments
- **[chrome](docs/examples/chrome.md)** - Chromium sandbox with VNC and DevTools access for automation and debugging.
- **[playwright](docs/examples/playwright.md)** - Playwright + Chromium headless scraping and testing example.
- **[desktop](docs/examples/desktop.md)** - Full desktop environment in a sandbox with VNC access.
- **[vscode](docs/examples/vscode.md)** - code-server (VS Code Web) running inside a sandbox for remote dev.
#### 🧠 Training and Evaluation
- **[rl-training](docs/examples/rl-training.md)** - DQN CartPole training in a sandbox with checkpoints and summary output.
- **[harbor-evaluation](docs/examples/harbor-evaluation.md)** - Run a [Harbor](https://github.com/harbor-framework/harbor) agent evaluation on OpenSandbox, one sandbox per trial.
For more details, please refer to the [examples documentation](docs/examples/index.md).
## Project Structure
| Directory | Description |
|-----------|------------------------------------------------------------------|
| [`sdks/`](sdks/) | Multi-language SDKs (Python, Java/Kotlin, TypeScript/JavaScript, C#/.NET) |
| [`specs/`](specs/README.md) | OpenAPI specs and lifecycle specifications |
| [`server/`](server/README.md) | Python FastAPI sandbox lifecycle server |
| [`cli/`](cli/README.md) | OpenSandbox command-line interface |
| [`kubernetes/`](kubernetes/README.md) | Kubernetes deployment and examples |
| [`components/execd/`](components/execd/README.md) | Sandbox execution daemon (commands and file operations) |
| [`components/ingress/`](components/ingress/README.md) | Sandbox traffic ingress proxy |
| [`components/egress/`](components/egress/README.md) | Sandbox network egress control |
| [`sandboxes/`](sandboxes/) | Runtime sandbox implementations |
| [`examples/`](examples/) | Runnable example code |
| [`docs/examples/`](docs/examples/index.md) | Example documentation and use cases |
| [`oseps/`](oseps/README.md) | OpenSandbox Enhancement Proposals |
| [`docs/`](docs/) | Architecture and design documentation |
| [`tests/`](tests/) | Cross-component E2E tests |
| [`scripts/`](scripts/) | Development and maintenance scripts |
For detailed architecture, see [Architecture](docs/architecture/).
## Documentation
- [Architecture](docs/architecture/) Overall architecture & design philosophy
- [Credential Vault](docs/guides/credential-vault.md) - Credential Vault credential injection guide
- [Release Verification](docs/community/release-verification.md) - Release signing and artifact verification
- [oseps/README.md](oseps/README.md) OpenSandbox Enhancement Proposals
- SDK
- Sandbox base SDK ([Java/Kotlin SDK](sdks/sandbox/kotlin/README.md), [Python SDK](sdks/sandbox/python/README.md), [JavaScript/TypeScript SDK](sdks/sandbox/javascript/README.md), [C#/.NET SDK](sdks/sandbox/csharp/README.md)), [Go SDK](sdks/sandbox/go/README.md) - includes sandbox lifecycle, command execution, file operations
- Code Interpreter SDK ([Java/Kotlin SDK](sdks/code-interpreter/kotlin/README.md), [Python SDK](sdks/code-interpreter/python/README.md), [JavaScript/TypeScript SDK](sdks/code-interpreter/javascript/README.md), [C#/.NET SDK](sdks/code-interpreter/csharp/README.md)) - code interpreter
- [cli/README.md](cli/README.md) - OpenSandbox CLI installation and command reference
- [sdks/mcp/sandbox/python/README.md](sdks/mcp/sandbox/python/README.md) - MCP server installation and client setup
- [specs/README.md](specs/README.md) - OpenAPI definitions for sandbox lifecycle API and sandbox execution API
- [server/README.md](server/README.md) - Sandbox server startup and configuration; supports Docker and Kubernetes runtimes
- [ROADMAP.md](ROADMAP.md) - Lightweight project roadmap and planning process
## License
This project is open source under the [Apache 2.0 License](LICENSE).
## Roadmap
See [ROADMAP.md](ROADMAP.md) for the current project roadmap, planning scope,
and how roadmap items are managed.
## Contact and Discussion
- Issues: Submit bugs, feature requests, or design discussions through GitHub Issues
- Discord: Join the [OpenSandbox Discord community](https://discord.gg/g7FuPs8YeD)
- DingTalk: Join the [OpenSandbox technical discussion group](https://qr.dingtalk.com/action/joingroup?code=v1,k1,A4Bgl5q1I1eNU/r33D18YFNrMY108aFF38V+r19RJOM=&_dt_no_comment=1&origin=11)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=opensandbox-group/OpenSandbox&type=date&legend=top-left)](https://www.star-history.com/#opensandbox-group/OpenSandbox&type=date&legend=top-left)
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`opensandbox-group/OpenSandbox`
- 原始仓库:https://github.com/opensandbox-group/OpenSandbox
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+81
View File
@@ -0,0 +1,81 @@
# OpenSandbox Roadmap
Last updated: 2026-04-28
This roadmap describes the intended project direction for roughly the next 12
months. It is a planning guide, not a release commitment. Implementation details
are tracked through GitHub Issues, pull requests, and OpenSandbox Enhancement
Proposals (OSEPs).
## Principles
- Keep public API contracts, SDKs, CLI behavior, implementation, examples, and
documentation aligned.
- Prefer additive, backward-compatible changes for public interfaces.
- Use the OSEP process for major architecture, API, runtime, or security-model
changes.
- Keep roadmap items focused on project direction instead of using this file as
a full backlog.
- Move completed work into release notes, OSEP status, or stable documentation
instead of keeping long historical task lists here.
## Current Focus: 2026 H1-H2
### Sandbox Runtime
| Area | Status | Tracking | Notes |
|------|--------|----------|-------|
| Local lightweight sandbox | Planned | TBD | Lightweight sandbox runtime for AI tools running directly on PCs. |
| Persistent volumes | Implementing | [OSEP-0003](oseps/0003-volume-and-volumebinding-support.md) | Close remaining runtime/backend gaps from OSEP-0003 before treating volume support as mature. |
| Secure container runtime | Maturing | [OSEP-0004](oseps/0004-secure-container-runtime.md), [secure container guide](docs/guides/secure-container.md) | Continue hardening isolation guidance and deployment practices. |
| Pause and resume via rootfs snapshot | Implementing | [OSEP-0008](oseps/0008-pause-resume-rootfs-snapshot.md) | Improve lifecycle support for stateful sandbox workflows. |
| Secure endpoint access | Implemented / maturing | [OSEP-0011](oseps/0011-secure-access-endpoint.md) | Keep endpoint security behavior aligned across server, SDKs, and docs. |
### SDKs and Developer Experience
| Area | Status | Tracking | Notes |
|------|--------|----------|-------|
| SDK parity | Ongoing | [sdks/](sdks/), [specs/](specs/README.md) | Keep Python, Go, Kotlin, JavaScript/TypeScript, and C# SDKs aligned with public specs. |
| Client-side sandbox pool | Implementing / maturing | [OSEP-0005](oseps/0005-client-side-sandbox-pool.md) | Expand behavior consistency, tests, and documentation where practical. |
| CLI usability | Planned | [cli/](cli/README.md) | Improve common sandbox lifecycle workflows and developer ergonomics. |
| Developer console | Implementable | [OSEP-0006](oseps/0006-developer-console.md) | Provide a clearer operational surface for sandbox users and maintainers. |
### Observability and Operations
| Area | Status | Tracking | Notes |
|------|--------|----------|-------|
| OpenTelemetry metrics and logs | Implementing | [OSEP-0010](oseps/0010-opentelemetry-instrumentation.md) | Add observability across execd, ingress, and egress. |
| Agent in-sandbox audit trail | Planned | TBD / OSEP needed | Define auditable records for agent actions inside sandboxes, such as command/session execution, file operations, network access, identity context, retention, and privacy boundaries. |
| Kubernetes deployment | Ongoing | [kubernetes/](kubernetes/README.md), [Helm charts](kubernetes/charts/) | Keep self-hosted deployment, chart, and operational documentation current. |
| Network isolation guidance | Ongoing | [network isolation guide](docs/architecture/network-isolation.md) | Continue documenting safe defaults and practical isolation patterns. |
### Public Contracts and Governance
| Area | Status | Tracking | Notes |
|------|--------|----------|-------|
| Lifecycle API stability | Ongoing | [specs/](specs/README.md), [OSEPs](oseps/README.md) | Preserve compatibility and require clear migration paths for user-visible changes. |
| Security documentation | Ongoing | [SECURITY.md](SECURITY.md), [docs/](docs/) | Keep vulnerability reporting, security expectations, and deployment guidance current. |
| Open project governance | Ongoing | [GOVERNANCE.md](GOVERNANCE.md), [CONTRIBUTING.md](CONTRIBUTING.md) | Maintain a lightweight, public decision process as the project grows. |
## Not Currently Planned
- Declaring a stable v1 API before lifecycle semantics, runtime behavior, and
SDK compatibility are mature enough to support it.
- Breaking public specs, SDK interfaces, CLI behavior, or documented workflows
without an OSEP and migration path.
- Provider-specific features that cannot be documented, tested, or isolated
cleanly from the public API surface.
- Heavy release-train governance before the project has enough maintainer
capacity to keep that process current.
## How Roadmap Items Are Managed
- Small work is tracked as GitHub Issues and pull requests.
- Major features, architecture changes, public API changes, runtime behavior
changes, and security-model changes start with an OSEP.
- Active roadmap entries should link to an issue, PR, OSEP, or documentation
page once a stable tracking location exists.
- Completed work should be reflected in release notes, implemented OSEPs, and
user documentation rather than remaining as an active roadmap item.
- Maintainers should review this file at least quarterly, or whenever a major
OSEP changes status.
+69
View File
@@ -0,0 +1,69 @@
# Security Policy
## Reporting Security Issues
The OpenSandbox team takes security seriously. If you discover a security vulnerability, please report it responsibly.
### How to Report
- **GitHub Private Vulnerability Reporting**: Open a
[private security advisory](https://github.com/opensandbox-group/OpenSandbox/security/advisories/new)
Do not report vulnerabilities in a public issue or to the Code of Conduct
reporting address. The `conduct@opensandbox.io` address is only for community
conduct reports.
### What to Include
- Clear description of the vulnerability
- Steps to reproduce
- Potential impact and scope
- Suggested remediation (if available)
## Response Process
1. Acknowledgment within 48 hours
2. Investigation and validation
3. Fix development and testing
4. Coordinated disclosure
## Supported Versions
Only the latest release and main branch are actively supported with security updates.
## Release Signatures
OpenSandbox signs public release outputs with GitHub/Sigstore attestations,
cosign keyless container signatures, and Maven Central package signatures where
applicable. See [Release Verification](docs/community/release-verification.md) for the
trusted signer identities and verification commands.
## Security Best Practices
When deploying OpenSandbox:
- Keep dependencies up to date
- Use network policies to restrict sandbox egress
- Monitor audit logs regularly
- Follow principle of least privilege
## Cryptographic Key Length Policy
OpenSandbox TLS defaults are aligned with OpenSSF `crypto_keylength` guidance
(NIST minimum strength through year 2030, as stated in 2012):
- symmetric key: at least 112 bits
- factoring modulus (RSA): at least 2048 bits
- discrete logarithm key: at least 224 bits
- discrete logarithm group: at least 2048 bits
- elliptic curve key: at least 224 bits
- hash algorithm strength: at least 224 bits
Project-owned enforcement points include:
- Go SDK default transport certificate validation
- Kubernetes controller validation for configured webhook/metrics TLS certificates
For controlled interoperability scenarios, legacy weaker key lengths can be explicitly enabled:
- Go SDK: set `TransportConfig.AllowWeakServerCertKeyLengths=true`
- Kubernetes controller: set `--allow-weak-tls-keylengths=true`
+115
View File
@@ -0,0 +1,115 @@
# CLI AGENTS
You are working on the OpenSandbox CLI. Keep commands as thin, predictable wrappers over the Python SDK, and keep help text, README examples, bundled skills, and tests aligned whenever user-visible CLI behavior changes.
## Scope
- `src/opensandbox_cli/**`
- `tests/**`
- `README.md`
- `pyproject.toml` and `uv.lock` when CLI dependencies or release metadata change
- `assets/**` only when screenshots or visual CLI documentation are intentionally refreshed
If the task changes SDK-facing behavior, also read `../sdks/AGENTS.md`. If the task is driven by public API contracts, also read `../specs/AGENTS.md`.
## Key Areas
- `src/opensandbox_cli/main.py`: root command registration, global options, version/banner behavior
- `src/opensandbox_cli/client.py`: resolved config, SDK manager/client construction, output formatter wiring
- `src/opensandbox_cli/commands/`: command groups and command-scoped options
- `src/opensandbox_cli/output.py`: table, JSON, YAML, and raw rendering behavior
- `src/opensandbox_cli/skills/`: bundled skills installed into external agent tools
- `src/opensandbox_cli/skill_registry.py`: skill metadata shown by `osb skills list/show`
- `tests/test_cli_help.py`: root and command help coverage
- `tests/test_commands.py`: SDK-backed command behavior with mocked SDK calls
- `tests/test_skills.py`: bundled skill quality and CLI alignment checks
## Command Design
Prefer clear command groups whose names match stable product concepts. Commands should call Python SDK facades such as `SandboxManager`, `Sandbox`, or service objects instead of rebuilding HTTP paths locally. Raw HTTP or private client access is acceptable only for explicitly experimental or legacy commands.
For new stable commands:
- expose concise flags with names that match SDK/API concepts
- support `-o/--output` consistently with nearby commands
- use `raw` only for payload text or streaming-style output
- use `json` / `yaml` for structured descriptors or SDK models
- register the command in `main.py`
- add help tests and mocked SDK command tests
- update README examples when the command is user-facing
- update bundled skills when agents should use the command
For deprecated commands:
- preserve old option meanings, especially short flags
- do not silently reuse an old flag for a new concept
- print or return explicit migration guidance
- keep compatibility wrappers small and route to the stable implementation when practical
## Skills
Bundled skills are operational guidance for real agents, not long-form documentation. Keep them concise, command-first, and aligned with actual CLI behavior.
When changing commands that appear in skills:
- update the relevant skill examples
- explain only the semantics agents need to make decisions
- keep examples executable and include explicit `-o` output formats
- avoid relying on deprecated CLI APIs unless the skill clearly marks them as fallback
- update `skill_registry.py` summaries when the skill's advertised behavior changes
- update `tests/test_skills.py` so command examples and option names stay aligned with the CLI
## Commands
Common CLI checks:
```bash
cd cli
uv run --frozen ruff check
uv run --frozen pyright
uv run --frozen pytest tests/ -q
```
Focused checks:
```bash
cd cli
uv run --frozen pytest tests/test_cli_help.py -q
uv run --frozen pytest tests/test_commands.py -q
uv run --frozen pytest tests/test_skills.py -q
```
Use `--frozen` for validation when you do not intend to update `uv.lock`. If `uv run` changes `uv.lock` unexpectedly, inspect the diff and keep it only when the dependency graph intentionally changed.
## Guardrails
Always:
- Keep CLI behavior aligned with the Python SDK surface it wraps.
- Keep help text accurate for supported options and output formats.
- Add or update tests for new commands, changed flags, changed rendering, and skill examples.
- Preserve command output compatibility unless the migration is explicit and documented.
- Treat bundled skills as part of the user-facing CLI surface.
- Keep command implementations small; put reusable rendering, validation, and error handling in local helpers when that matches existing style.
- Mention verification that was not run in the final handoff.
Ask first:
- Removing a command or flag.
- Changing the meaning of an existing option or short flag.
- Making a legacy or experimental command the preferred path without a migration story.
- Changing installed skill formats or target layouts.
Never:
- Reimplement stable SDK APIs with ad hoc HTTP calls in a new stable CLI command.
- Update generated or lock files as incidental noise.
- Leave README, help text, or bundled skills pointing at deprecated commands after adding a stable replacement.
- Mix unrelated CLI feature work into a command behavior change.
## Good Patterns
- Stable command wraps SDK manager/service method and shares rendering helpers.
- Legacy command delegates to the stable implementation and emits migration guidance.
- Tests mock `ClientContext` and assert exact SDK calls.
- Skill tests assert that examples use existing commands and explicit output formats.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+409
View File
@@ -0,0 +1,409 @@
# OpenSandbox CLI
`osb` is the command-line interface for OpenSandbox. It is built for the common day-to-day flows:
- create and manage sandboxes
- run commands inside a sandbox
- read and modify sandbox files
- inspect runtime egress policy
- manage sandbox-local Credential Vault state
- collect low-level diagnostics
- install OpenSandbox-specific skills for coding agents
It uses the OpenSandbox Python SDK under the hood and is intended to be the shortest path from a terminal to a working sandbox workflow.
## Install
Choose one:
```bash
pip install opensandbox-cli
```
```bash
uv tool install opensandbox-cli
```
```bash
pipx install opensandbox-cli
```
Confirm the install:
```bash
osb --help
osb --version
```
## Before You Start
Make sure an OpenSandbox server is reachable. If you are running locally, start the server first and then point the CLI at it.
```bash
opensandbox-server
```
## Quick Start
### 1. Initialize config
```bash
osb config init
osb config set connection.domain localhost:8080
osb config set connection.protocol http
osb config set connection.api_key <your-api-key>
osb config show -o json
```
If you want a non-default config file, choose it at the root command level for the whole invocation:
```bash
osb --config /tmp/dev.toml config init
osb --config /tmp/dev.toml config set connection.domain localhost:8080
osb --config /tmp/dev.toml config show -o json
```
### 2. Create a sandbox
```bash
osb sandbox create --image python:3.12 --timeout 30m -o json
```
If you set defaults first, later create commands can be shorter:
```bash
osb config set defaults.image python:3.12
osb config set defaults.timeout 30m
osb sandbox create -o json
```
### 3. Verify it is usable
```bash
osb sandbox get <sandbox-id> -o json
osb sandbox health <sandbox-id> -o json
```
### 4. Run a command inside the sandbox
Use `--` before the sandbox command payload.
```bash
osb command run <sandbox-id> -o raw -- python -c "print(1 + 1)"
```
### 5. Read or write a file
```bash
osb file write <sandbox-id> /workspace/hello.txt -c "hello" -o json
osb file cat <sandbox-id> /workspace/hello.txt -o raw
```
### 6. Clean up
```bash
osb sandbox kill <sandbox-id> -o json
```
## Common Tasks
### Create sandboxes
Basic:
```bash
osb sandbox create --image python:3.12
```
Private image:
```bash
osb sandbox create \
--image my-registry.example.com/team/app:latest \
--image-auth-username alice \
--image-auth-password <token>
```
Manual cleanup mode:
```bash
osb sandbox create --image python:3.12 --timeout none
```
Explicit entrypoint argv:
```bash
osb sandbox create \
--image python:3.12 \
--entrypoint python \
--entrypoint -m \
--entrypoint http.server
```
Create with network policy and volumes:
```bash
osb sandbox create \
--image python:3.12 \
--network-policy-file network-policy.json \
--volumes-file volumes.json
```
Create with Credential Vault proxy enabled:
```bash
osb sandbox create --image python:3.12 --network-policy-file network-policy.json --credential-proxy -o json
```
### List and inspect sandboxes
```bash
osb sandbox list
osb sandbox list -o json
osb sandbox list --state running --state paused
osb sandbox get <sandbox-id> -o json
osb sandbox metrics <sandbox-id>
osb sandbox metrics <sandbox-id> --watch -o raw
```
### Expose a service
```bash
osb sandbox endpoint <sandbox-id> --port 8080 -o json
```
### Run commands
Foreground streaming:
```bash
osb command run <sandbox-id> -o raw -- sh -lc 'echo ready'
```
Tracked background execution:
```bash
osb command run <sandbox-id> --background -o json -- sh -c "sleep 10; echo done"
osb command status <sandbox-id> <execution-id> -o json
osb command logs <sandbox-id> <execution-id> -o json
```
Persistent shell session:
```bash
osb command session create <sandbox-id> --workdir /workspace -o json
osb command session run <sandbox-id> <session-id> -o raw -- pwd
osb command session run <sandbox-id> <session-id> -o raw -- export FOO=bar
osb command session run <sandbox-id> <session-id> -o raw -- sh -c 'echo $FOO'
osb command session delete <sandbox-id> <session-id> -o json
```
### Work with files
```bash
osb file upload <sandbox-id> ./local.txt /workspace/local.txt -o json
osb file download <sandbox-id> /workspace/result.json ./result.json -o json
osb file search <sandbox-id> /workspace --pattern "*.py" -o json
osb file info <sandbox-id> /workspace/main.py -o json
osb file replace <sandbox-id> /workspace/app.py --old old --new new -o json
osb file chmod <sandbox-id> /workspace/script.sh --mode 755 -o json
```
### Manage runtime egress policy
Inspect current policy:
```bash
osb egress get <sandbox-id> -o json
```
Patch specific rules:
```bash
osb egress patch <sandbox-id> --rule allow=pypi.org --rule deny=internal.example.com -o json
```
If you are debugging connectivity, verify behavior with an actual command:
```bash
osb command run <sandbox-id> -o raw -- curl -I https://pypi.org
```
### Manage Credential Vault
Credential Vault operations call the sandbox egress sidecar through the Python SDK.
Create the sandbox with `--credential-proxy` and an explicit network policy before
writing vault state.
```bash
osb credential-vault create <sandbox-id> --file vault.yaml -o json
osb credential-vault get <sandbox-id> -o json
osb credential-vault patch <sandbox-id> --file mutation.yaml -o json
osb credential-vault credential list <sandbox-id> -o json
osb credential-vault binding list <sandbox-id> -o json
osb credential-vault delete <sandbox-id> -o json
```
Use `--file -` to read a JSON/YAML payload from stdin. Do not pass plaintext
credential values as command-line flags; keep them in the payload stream or file.
### Collect diagnostics
Use the stable diagnostics commands for API-backed log and event descriptors.
```bash
osb diagnostics events <sandbox-id> --scope lifecycle -o raw
osb diagnostics events <sandbox-id> --scope runtime -o raw
osb diagnostics logs <sandbox-id> --scope container -o raw
osb diagnostics logs <sandbox-id> --scope lifecycle -o json
osb diagnostics events <sandbox-id> --scope runtime -o json
osb diagnostics logs <sandbox-id> --scope container -o yaml
```
`--scope` is required for stable diagnostics. Common scopes are `lifecycle` and
`container` for logs, and `lifecycle` and `runtime` for events. Raw output
prints inline diagnostic text, or the content URL when diagnostics are
delivered as a temporary URL. Structured CLI output follows the SDK/Python field
style, for example `content_url`, `content_length`, and `expires_at`.
Some server builds may return `DIAGNOSTICS_NOT_IMPLEMENTED` for scoped
diagnostics until the stable backend implementation is enabled.
Legacy DevOps diagnostics remain experimental. Prefer `osb diagnostics logs/events`
for stable API-backed log and event collection.
```bash
osb devops inspect <sandbox-id> -o raw
osb devops summary <sandbox-id> -o raw
```
## Output Formats
Output selection is command-scoped, not global.
- `table`: human-readable tables and panels
- `json`: machine-readable JSON
- `yaml`: machine-readable YAML
- `raw`: unformatted text or streaming output
Examples:
```bash
osb sandbox list -o json
osb sandbox list -o yaml
osb file cat <sandbox-id> /workspace/hello.txt -o raw
```
Not every command supports every format. Use `--help` on the specific command when in doubt.
## Command Groups
The main command groups are:
- `osb sandbox`: lifecycle management
- `osb command`: command execution and persistent sessions
- `osb file`: file and directory operations
- `osb egress`: runtime egress policy
- `osb diagnostics`: stable diagnostics logs and events
- `osb devops`: experimental legacy diagnostics
- `osb config`: local CLI configuration
- `osb skills`: bundled skills for AI tools
Explore them directly:
```bash
osb sandbox --help
osb command --help
osb file --help
osb skills --help
```
## Agent Skills
The CLI ships with built-in OpenSandbox skills for coding agents and agent-oriented tools.
Bundled skills:
- `sandbox-lifecycle`
- `command-execution`
- `file-operations`
- `network-egress`
- `sandbox-troubleshooting`
Supported targets:
| Target | Install location |
| --- | --- |
| `claude` | `./.claude/skills/` or `~/.claude/skills/` |
| `cursor` | `./.cursor/rules/` or `~/.cursor/rules/` |
| `codex` | `./.codex/skills/<name>/SKILL.md` or `~/.codex/skills/<name>/SKILL.md` |
| `copilot` | `./.github/copilot-instructions.md` or `~/.github/copilot-instructions.md` |
| `windsurf` | `./.windsurfrules` or `~/.windsurfrules` |
| `cline` | `./.clinerules` or `~/.clinerules` |
| `opencode` | `./.agents/skills/<name>/SKILL.md` or `~/.agents/skills/<name>/SKILL.md` |
Common flows:
```bash
osb skills list
osb skills show sandbox-lifecycle
osb skills install sandbox-lifecycle --target codex --scope project
osb skills install --all-builtins --target codex --scope global
osb skills uninstall sandbox-troubleshooting --target claude --scope global
```
For scripts or agents, use structured output:
```bash
osb skills install sandbox-lifecycle --target codex --scope project -o json
```
## Configuration Model
The CLI resolves configuration in this order:
1. root CLI flags such as `--api-key`, `--domain`, `--protocol`, `--request-timeout`, `--config`
2. environment variables such as `OPEN_SANDBOX_API_KEY` and `OPEN_SANDBOX_DOMAIN`
3. config file, defaulting to `~/.opensandbox/config.toml`
4. SDK defaults
Config commands:
```bash
osb config init
osb config show
osb config set connection.domain localhost:8080
osb config set connection.protocol http
osb config set defaults.image python:3.12
osb config set defaults.timeout 30m
```
Example config file:
```toml
[connection]
api_key = "your-api-key"
domain = "localhost:8080"
protocol = "http"
request_timeout = 30
use_server_proxy = false
[output]
color = true
[defaults]
image = "python:3.12"
timeout = "30m"
```
## Development
For local development in this monorepo:
```bash
cd cli
uv sync
uv run osb --help
uv run pytest
```
This repository uses a local `uv` source override for the OpenSandbox Python SDK, so running from `cli/` will resolve against the checked-out SDK in the monorepo.
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

+136
View File
@@ -0,0 +1,136 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
name = "opensandbox-cli"
dynamic = ["version"]
description = "OpenSandbox CLI - Command-line interface for managing sandboxes"
authors = [
{ name = "OpenSandbox Team", email = "ninan.nn@alibaba-inc.com" }
]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
keywords = ["sandbox", "cli", "opensandbox"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries",
"Typing :: Typed",
]
dependencies = [
"opensandbox>=0.1.9,<0.2.0",
"click>=8.1.0,<9.0",
"rich>=13.0.0,<14.0",
"pyyaml>=6.0,<7.0",
"tomli>=2.0.0; python_version < '3.11'",
]
[project.urls]
Homepage = "https://open-sandbox.ai"
Repository = "https://github.com/opensandbox-group/OpenSandbox"
Documentation = "https://open-sandbox.ai"
Issues = "https://github.com/opensandbox-group/OpenSandbox/issues"
[project.scripts]
opensandbox = "opensandbox_cli.main:cli"
osb = "opensandbox_cli.main:cli"
[tool.hatch.version]
source = "vcs"
[tool.hatch.version.raw-options]
root = ".."
tag_regex = "^cli/v(?P<version>\\d+\\.\\d+\\.\\d+(?:[\\.\\w\\+\\-]*)?)$"
git_describe_command = 'git describe --dirty --tags --long --match "cli/v*"'
fallback_version = "0.1.1"
[tool.hatch.build]
include = [
"LICENSE",
"src/**/py.typed",
"src/opensandbox_cli",
"src/opensandbox_cli/skills/**",
]
[tool.hatch.build.targets.wheel]
packages = ["src/opensandbox_cli"]
[dependency-groups]
dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"ruff>=0.14.8",
"pyright>=1.1.0",
]
[tool.ruff]
target-version = "py310"
line-length = 88
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
]
ignore = [
"E501", # line too long, handled by formatter
"B008", # do not perform function calls in argument defaults
"C901", # too complex
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]
[tool.pyright]
typeCheckingMode = "standard"
pythonVersion = "3.10"
pythonPlatform = "All"
include = ["src"]
exclude = [
"**/node_modules",
"**/__pycache__",
]
reportMissingImports = true
reportMissingTypeStubs = false
[tool.pytest.ini_options]
minversion = "6.0"
addopts = "-ra -q --strict-markers --strict-config"
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
[tool.coverage.run]
source = ["src"]
branch = true
[tool.uv.sources]
opensandbox = { path = "../sdks/sandbox/python", editable = true }
+20
View File
@@ -0,0 +1,20 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
try:
from importlib.metadata import version
__version__ = version("opensandbox-cli")
except Exception:
__version__ = "0.0.0-dev"
+20
View File
@@ -0,0 +1,20 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Allow running as ``python -m opensandbox_cli``."""
from opensandbox_cli.main import cli
if __name__ == "__main__":
cli()
+127
View File
@@ -0,0 +1,127 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""SDK client factory stored in Click context."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import timedelta
from pathlib import Path
from typing import Any
import httpx
from opensandbox.config.connection_sync import ConnectionConfigSync
from opensandbox.sync.manager import SandboxManagerSync
from opensandbox.sync.sandbox import SandboxSync
from opensandbox_cli.output import OutputFormatter
@dataclass
class ClientContext:
"""Shared context passed via ``ctx.obj`` to all Click commands."""
resolved_config: dict[str, Any]
config_path: Path
cli_overrides: dict[str, Any]
output: OutputFormatter = field(init=False)
_connection_config: ConnectionConfigSync | None = field(
default=None, init=False, repr=False
)
_manager: SandboxManagerSync | None = field(
default=None, init=False, repr=False
)
_devops_client: httpx.Client | None = field(
default=None, init=False, repr=False
)
def __post_init__(self) -> None:
"""Initialize a default human-readable formatter for the command context."""
self.output = OutputFormatter(
"table",
color=self.resolved_config.get("color", True),
)
@property
def connection_config(self) -> ConnectionConfigSync:
if self._connection_config is None:
cfg = self.resolved_config
self._connection_config = ConnectionConfigSync(
api_key=cfg.get("api_key"),
domain=cfg.get("domain"),
protocol=cfg.get("protocol", "http"),
request_timeout=timedelta(seconds=cfg.get("request_timeout", 30)),
use_server_proxy=cfg.get("use_server_proxy", False),
)
return self._connection_config
def get_devops_client(self) -> httpx.Client:
"""Return a cached HTTP client for experimental diagnostics endpoints."""
if self._devops_client is None:
config = self.connection_config
headers = dict(config.headers)
headers.setdefault("Accept", "text/plain")
headers.setdefault("User-Agent", config.user_agent)
if config.api_key:
headers["OPEN-SANDBOX-API-KEY"] = config.api_key
self._devops_client = httpx.Client(
base_url=config.get_base_url(),
headers=headers,
timeout=config.request_timeout.total_seconds(),
)
return self._devops_client
def get_manager(self) -> SandboxManagerSync:
"""Return a lazily-created ``SandboxManagerSync``."""
if self._manager is None:
self._manager = SandboxManagerSync.create(self.connection_config)
return self._manager
def resolve_sandbox_id(self, sandbox_id: str) -> str:
"""Return the sandbox ID exactly as provided by the user."""
return sandbox_id
def connect_sandbox(
self, sandbox_id: str, *, skip_health_check: bool = True
) -> SandboxSync:
"""Connect to an existing sandbox by ID."""
sandbox_id = self.resolve_sandbox_id(sandbox_id)
return SandboxSync.connect(
sandbox_id,
connection_config=self.connection_config,
skip_health_check=skip_health_check,
)
def make_output(self, fmt: str) -> OutputFormatter:
"""Create and cache the current command-scoped output formatter."""
formatter = OutputFormatter(
fmt,
color=self.resolved_config.get("color", True),
)
self.output = formatter
return formatter
def close(self) -> None:
"""Release resources."""
if self._manager is not None:
self._manager.close()
self._manager = None
if self._devops_client is not None:
self._devops_client.close()
self._devops_client = None
if self._connection_config is not None:
self._connection_config.close_transport_if_owned()
self._connection_config = None
@@ -0,0 +1,13 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
+359
View File
@@ -0,0 +1,359 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Command execution commands: one interface, foreground stream or background tracking."""
from __future__ import annotations
import shlex
import sys
from datetime import timedelta
import click
from opensandbox.models.execd import OutputMessage, RunCommandOpts
from opensandbox.models.execd_sync import ExecutionHandlersSync
from opensandbox_cli.client import ClientContext
from opensandbox_cli.utils import (
DURATION,
handle_errors,
output_option,
prepare_output,
)
@click.group("command", invoke_without_command=True)
@click.pass_context
def command_group(ctx: click.Context) -> None:
"""⚡ Execute commands in a sandbox."""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
# ---- run ------------------------------------------------------------------
def _run_command(
obj: ClientContext,
sandbox_id: str,
command: tuple[str, ...],
background: bool,
workdir: str | None,
timeout: timedelta | None,
output_format: str | None,
) -> None:
"""Shared implementation for ``command run``.
Mode contract:
- foreground (default): stream output directly, allow only ``-o raw``
- background (``--background``): return a tracked execution object, allow structured output
"""
allowed = ("table", "json", "yaml") if background else ("raw",)
fallback = "table" if background else "raw"
prepare_output(obj, output_format, allowed=allowed, fallback=fallback)
cmd_str = " ".join(shlex.quote(arg) for arg in command)
sandbox = obj.connect_sandbox(sandbox_id)
try:
opts = RunCommandOpts(
background=background,
working_directory=workdir,
timeout=timeout,
)
if background:
execution = sandbox.commands.run(cmd_str, opts=opts)
obj.output.success_panel(
{
"execution_id": execution.id,
"sandbox_id": sandbox_id,
"mode": "background",
},
title="Background Command Started",
)
return
# Foreground: stream stdout/stderr to terminal
last_text = ""
def on_stdout(msg: OutputMessage) -> None:
nonlocal last_text
last_text = msg.text
sys.stdout.write(msg.text)
sys.stdout.flush()
def on_stderr(msg: OutputMessage) -> None:
nonlocal last_text
last_text = msg.text
sys.stderr.write(msg.text)
sys.stderr.flush()
handlers = ExecutionHandlersSync(on_stdout=on_stdout, on_stderr=on_stderr)
execution = sandbox.commands.run(cmd_str, opts=opts, handlers=handlers)
# Ensure terminal prompt starts on a new line
if last_text and not last_text.endswith("\n"):
sys.stdout.write("\n")
sys.stdout.flush()
_handle_execution_error(obj, execution)
finally:
sandbox.close()
def _handle_execution_error(obj: ClientContext, execution) -> None:
"""Exit non-zero if the execution finished with an error."""
if execution.error:
obj.output.error_panel(
f"{execution.error.name}: {execution.error.value}",
title="Execution Error",
)
sys.exit(1)
@command_group.command(
"run",
help="Run a command in a sandbox. Use `--` before the sandbox command payload.",
epilog="Separator rule: use `--` before the sandbox command payload.",
)
@click.argument("sandbox_id")
@click.argument("command", nargs=-1, required=True)
@click.option("-d", "--background", is_flag=True, default=False, help="Run in background.")
@click.option("-w", "--workdir", default=None, help="Working directory.")
@click.option("-t", "--timeout", type=DURATION, default=None, help="Command timeout (e.g. 30s, 5m).")
@output_option("table", "json", "yaml", "raw")
@click.pass_obj
@handle_errors
def command_run(
obj: ClientContext,
sandbox_id: str,
command: tuple[str, ...],
background: bool,
workdir: str | None,
timeout: timedelta | None,
output_format: str | None,
) -> None:
"""Run a command in a sandbox.
Default mode streams output directly. Add ``--background`` to return a
tracked execution object instead. Use ``--`` before the sandbox command payload.
"""
_run_command(
obj,
sandbox_id,
command,
background,
workdir,
timeout,
output_format,
)
# ---- status ---------------------------------------------------------------
@command_group.command("status")
@click.argument("sandbox_id")
@click.argument("execution_id")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def command_status(
obj: ClientContext,
sandbox_id: str,
execution_id: str,
output_format: str | None,
) -> None:
"""Get command execution status."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox = obj.connect_sandbox(sandbox_id)
try:
status = sandbox.commands.get_command_status(execution_id)
obj.output.print_model(status, title="Command Status")
finally:
sandbox.close()
# ---- logs -----------------------------------------------------------------
@command_group.command("logs")
@click.argument("sandbox_id")
@click.argument("execution_id")
@click.option("--cursor", type=int, default=None, help="Cursor for incremental reads.")
@output_option("table", "json", "yaml", "raw")
@click.pass_obj
@handle_errors
def command_logs(
obj: ClientContext,
sandbox_id: str,
execution_id: str,
cursor: int | None,
output_format: str | None,
) -> None:
"""Get background command logs."""
prepare_output(
obj, output_format, allowed=("table", "json", "yaml", "raw"), fallback="table"
)
sandbox = obj.connect_sandbox(sandbox_id)
try:
logs = sandbox.commands.get_background_command_logs(execution_id, cursor=cursor)
if obj.output.fmt in ("json", "yaml"):
obj.output.print_model(logs, title="Command Logs")
elif obj.output.fmt == "raw":
click.echo(logs.content)
else:
obj.output.panel(logs.content, title="Command Logs")
finally:
sandbox.close()
# ---- interrupt ------------------------------------------------------------
@command_group.command("interrupt")
@click.argument("sandbox_id")
@click.argument("execution_id")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def command_interrupt(
obj: ClientContext,
sandbox_id: str,
execution_id: str,
output_format: str | None,
) -> None:
"""Interrupt a running command."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox = obj.connect_sandbox(sandbox_id)
try:
sandbox.commands.interrupt(execution_id)
obj.output.success(f"Interrupted: {execution_id}")
finally:
sandbox.close()
@command_group.group("session", invoke_without_command=True)
@click.pass_context
def session_group(ctx: click.Context) -> None:
"""Manage persistent bash sessions."""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
@session_group.command("create")
@click.argument("sandbox_id")
@click.option("-w", "--workdir", default=None, help="Initial working directory.")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def session_create(
obj: ClientContext,
sandbox_id: str,
workdir: str | None,
output_format: str | None,
) -> None:
"""Create a persistent bash session."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox = obj.connect_sandbox(sandbox_id)
try:
session_id = sandbox.commands.create_session(working_directory=workdir)
obj.output.success_panel(
{
"sandbox_id": sandbox.id,
"session_id": session_id,
"working_directory": workdir,
},
title="Session Created",
)
finally:
sandbox.close()
@session_group.command(
"run",
help="Run a command in an existing bash session. Use `--` before the sandbox command payload.",
epilog="Separator rule: use `--` before the sandbox command payload.",
)
@click.argument("sandbox_id")
@click.argument("session_id")
@click.argument("command", nargs=-1, required=True)
@click.option("-w", "--workdir", default=None, help="Working directory override for this run.")
@click.option("-t", "--timeout", type=DURATION, default=None, help="Command timeout (e.g. 30s, 5m).")
@output_option("raw", help_text="Output format: raw.")
@click.pass_obj
@handle_errors
def session_run(
obj: ClientContext,
sandbox_id: str,
session_id: str,
command: tuple[str, ...],
workdir: str | None,
timeout: timedelta | None,
output_format: str | None,
) -> None:
"""Run a command in an existing bash session. Use `--` before the sandbox command payload."""
prepare_output(obj, output_format, allowed=("raw",), fallback="raw")
cmd_str = " ".join(shlex.quote(arg) for arg in command)
sandbox = obj.connect_sandbox(sandbox_id)
try:
last_text = ""
def on_stdout(msg: OutputMessage) -> None:
nonlocal last_text
last_text = msg.text
sys.stdout.write(msg.text)
sys.stdout.flush()
def on_stderr(msg: OutputMessage) -> None:
nonlocal last_text
last_text = msg.text
sys.stderr.write(msg.text)
sys.stderr.flush()
handlers = ExecutionHandlersSync(on_stdout=on_stdout, on_stderr=on_stderr)
execution = sandbox.commands.run_in_session(
session_id,
cmd_str,
working_directory=workdir,
timeout=timeout,
handlers=handlers,
)
if last_text and not last_text.endswith("\n"):
sys.stdout.write("\n")
sys.stdout.flush()
_handle_execution_error(obj, execution)
finally:
sandbox.close()
@session_group.command("delete")
@click.argument("sandbox_id")
@click.argument("session_id")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def session_delete(
obj: ClientContext,
sandbox_id: str,
session_id: str,
output_format: str | None,
) -> None:
"""Delete a persistent bash session."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox = obj.connect_sandbox(sandbox_id)
try:
sandbox.commands.delete_session(session_id)
obj.output.success(f"Deleted session: {session_id}")
finally:
sandbox.close()
@@ -0,0 +1,183 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Config management commands: init, show, set."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
import click
from opensandbox_cli.client import ClientContext
from opensandbox_cli.config import init_config_file, resolve_config
from opensandbox_cli.utils import handle_errors, output_option, prepare_output
@click.group("config", invoke_without_command=True)
@click.pass_context
def config_group(ctx: click.Context) -> None:
"""⚙️ Manage CLI configuration."""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
# ---- init -----------------------------------------------------------------
@config_group.command("init")
@click.option("--force", is_flag=True, default=False, help="Overwrite existing config file.")
@output_option("table", "json", "yaml")
@handle_errors
@click.pass_obj
def config_init(obj: ClientContext, force: bool, output_format: str | None) -> None:
"""Create a default configuration file."""
output = prepare_output(
obj, output_format, allowed=("table", "json", "yaml"), fallback="table"
)
try:
path = init_config_file(obj.config_path, force=force)
output.success(f"Config file created: {path}")
except FileExistsError as exc:
output.warning(str(exc))
# ---- show -----------------------------------------------------------------
_SENSITIVE_CONFIG_KEYS = {
"api_key",
}
def _mask_secret(value: str | None) -> str | None:
"""Mask a secret while keeping enough shape for debugging."""
if value is None:
return None
if len(value) <= 4:
return "*" * len(value)
return f"{value[:2]}{'*' * (len(value) - 4)}{value[-2:]}"
def _sanitize_config_for_display(data: Mapping[str, Any]) -> dict[str, Any]:
"""Return a display-safe copy of the resolved config."""
sanitized: dict[str, Any] = {}
for key, value in data.items():
if key in _SENSITIVE_CONFIG_KEYS:
sanitized[key] = _mask_secret(value if isinstance(value, str) else None)
continue
sanitized[key] = value
return sanitized
@config_group.command("show")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def config_show(obj: ClientContext, output_format: str | None) -> None:
"""Show the resolved configuration."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
resolved = resolve_config(
cli_api_key=obj.cli_overrides.get("api_key"),
cli_domain=obj.cli_overrides.get("domain"),
cli_protocol=obj.cli_overrides.get("protocol"),
cli_timeout=obj.cli_overrides.get("request_timeout"),
cli_use_server_proxy=obj.cli_overrides.get("use_server_proxy"),
config_path=obj.config_path,
)
obj.output.print_dict(
{
**_sanitize_config_for_display(resolved),
"config_path": str(obj.config_path),
"config_file_exists": obj.config_path.exists(),
},
title="Resolved Configuration",
)
# ---- set ------------------------------------------------------------------
@config_group.command("set")
@click.argument("key")
@click.argument("value")
@output_option("table", "json", "yaml")
@handle_errors
@click.pass_obj
def config_set(
obj: ClientContext,
key: str,
value: str,
output_format: str | None,
) -> None:
"""Set a configuration value (e.g. 'connection.domain' 'localhost:9090')."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
path = obj.config_path
if not path.exists():
raise click.ClickException(f"Config file not found: {path}. Run 'osb config init' first.")
content = path.read_text()
# TODO: Replace this regex-based TOML editing with a parser-backed update
# path so formatting/comments survive reliably as config complexity grows.
# Simple key replacement in TOML
# Supports dotted keys like connection.domain
parts = key.split(".", 1)
if len(parts) == 2:
section, field = parts
# Try to find and update existing value
import re
section_pattern = rf"(\[{re.escape(section)}\].*?)(?=\n\[|\Z)"
section_match = re.search(section_pattern, content, re.DOTALL)
# Infer TOML value type: bool > int > float > string
def _toml_value(raw: str) -> str:
if raw.lower() in ("true", "false"):
return raw.lower()
try:
int(raw)
return raw
except ValueError:
pass
try:
float(raw)
return raw
except ValueError:
pass
return f'"{raw}"'
toml_val = _toml_value(value)
if section_match:
section_text = section_match.group(1)
field_pattern = rf'^(#?\s*{re.escape(field)}\s*=\s*).*$'
field_match = re.search(field_pattern, section_text, re.MULTILINE)
if field_match:
new_line = f'{field} = {toml_val}'
new_section = section_text[:field_match.start()] + new_line + section_text[field_match.end():]
content = content[:section_match.start()] + new_section + content[section_match.end():]
else:
# Add field to section
insert_pos = section_match.end()
content = content[:insert_pos] + f'\n{field} = {toml_val}' + content[insert_pos:]
else:
# Add new section
content += f'\n[{section}]\n{field} = {toml_val}\n'
else:
raise click.ClickException(
"Key must be in 'section.field' format (e.g. connection.domain)."
)
path.write_text(content)
obj.output.success(f"Set {key} = {value}")
@@ -0,0 +1,306 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Sandbox-local Credential Vault commands."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import click
import yaml
from opensandbox_cli.client import ClientContext
from opensandbox_cli.utils import handle_errors, output_option, prepare_output
def _read_payload(payload_file: str) -> dict[str, Any]:
"""Read a JSON/YAML object from a file path or stdin."""
try:
if payload_file == "-":
raw = click.get_text_stream("stdin").read()
else:
path = Path(payload_file)
if not path.is_file():
raise click.ClickException(f"Payload file not found: {payload_file}")
raw = path.read_text(encoding="utf-8")
payload = yaml.safe_load(raw)
except click.ClickException:
raise
except Exception as exc:
raise click.ClickException(
f"Failed to read credential vault payload from {payload_file}."
) from exc
if not isinstance(payload, dict):
raise click.ClickException("Credential vault payload must be a JSON/YAML object.")
return payload
def _require_list(payload: dict[str, Any], key: str) -> list[Any]:
value = payload.get(key)
if not isinstance(value, list):
raise click.ClickException(f"Credential vault payload field '{key}' must be a list.")
return value
def _get_expected_revision(payload: dict[str, Any]) -> int | None:
raw = payload.get("expectedRevision", payload.get("expected_revision"))
if raw is None:
return None
if type(raw) is not int:
raise click.ClickException("Credential vault expectedRevision must be an integer.")
return raw
def _optional_object(payload: dict[str, Any], key: str) -> dict[str, Any] | None:
value = payload.get(key)
if value is None:
return None
if not isinstance(value, dict):
raise click.ClickException(f"Credential vault patch field '{key}' must be an object.")
return value
@click.group("credential-vault", invoke_without_command=True)
@click.pass_context
def credential_vault_group(ctx: click.Context) -> None:
"""Manage sandbox-local Credential Vault state."""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
@credential_vault_group.command("create")
@click.argument("sandbox_id")
@click.option(
"--file",
"payload_file",
required=True,
help="JSON/YAML payload file, or '-' to read from stdin.",
)
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def credential_vault_create(
obj: ClientContext,
sandbox_id: str,
payload_file: str,
output_format: str | None,
) -> None:
"""Create the initial Credential Vault state."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
payload = _read_payload(payload_file)
credentials = _require_list(payload, "credentials")
bindings = _require_list(payload, "bindings")
sandbox = obj.connect_sandbox(sandbox_id)
try:
state = sandbox.credential_vault.create(
credentials=credentials,
bindings=bindings,
)
obj.output.print_model(state, title="Credential Vault")
finally:
sandbox.close()
@credential_vault_group.command("get")
@click.argument("sandbox_id")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def credential_vault_get(
obj: ClientContext,
sandbox_id: str,
output_format: str | None,
) -> None:
"""Get sanitized Credential Vault state."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox = obj.connect_sandbox(sandbox_id)
try:
state = sandbox.credential_vault.get()
obj.output.print_model(state, title="Credential Vault")
finally:
sandbox.close()
@credential_vault_group.command("patch")
@click.argument("sandbox_id")
@click.option(
"--file",
"payload_file",
required=True,
help="JSON/YAML mutation payload file, or '-' to read from stdin.",
)
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def credential_vault_patch(
obj: ClientContext,
sandbox_id: str,
payload_file: str,
output_format: str | None,
) -> None:
"""Atomically mutate credentials and bindings."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
payload = _read_payload(payload_file)
credentials = _optional_object(payload, "credentials")
bindings = _optional_object(payload, "bindings")
if credentials is None and bindings is None:
raise click.ClickException(
"Credential vault patch payload must include credentials or bindings."
)
sandbox = obj.connect_sandbox(sandbox_id)
try:
state = sandbox.credential_vault.patch(
expected_revision=_get_expected_revision(payload),
credentials=credentials,
bindings=bindings,
)
obj.output.print_model(state, title="Credential Vault")
finally:
sandbox.close()
@credential_vault_group.command("delete")
@click.argument("sandbox_id")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def credential_vault_delete(
obj: ClientContext,
sandbox_id: str,
output_format: str | None,
) -> None:
"""Delete the sandbox-local Credential Vault."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox = obj.connect_sandbox(sandbox_id)
try:
sandbox.credential_vault.delete()
obj.output.success_panel(
{"sandbox_id": sandbox.id, "status": "deleted"},
title="Credential Vault Deleted",
)
finally:
sandbox.close()
@credential_vault_group.group("credential", invoke_without_command=True)
@click.pass_context
def credential_group(ctx: click.Context) -> None:
"""Inspect sanitized Credential Vault credential metadata."""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
@credential_group.command("list")
@click.argument("sandbox_id")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def credential_list(
obj: ClientContext,
sandbox_id: str,
output_format: str | None,
) -> None:
"""List sanitized credential metadata."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox = obj.connect_sandbox(sandbox_id)
try:
credentials = sandbox.credential_vault.list_credentials()
obj.output.print_models(
credentials,
columns=["name", "source_type", "revision"],
title="Credential Vault Credentials",
)
finally:
sandbox.close()
@credential_group.command("get")
@click.argument("sandbox_id")
@click.argument("credential_name")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def credential_get(
obj: ClientContext,
sandbox_id: str,
credential_name: str,
output_format: str | None,
) -> None:
"""Get sanitized metadata for one credential."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox = obj.connect_sandbox(sandbox_id)
try:
credential = sandbox.credential_vault.get_credential(credential_name)
obj.output.print_model(credential, title="Credential Vault Credential")
finally:
sandbox.close()
@credential_vault_group.group("binding", invoke_without_command=True)
@click.pass_context
def binding_group(ctx: click.Context) -> None:
"""Inspect sanitized Credential Vault binding metadata."""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
@binding_group.command("list")
@click.argument("sandbox_id")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def binding_list(
obj: ClientContext,
sandbox_id: str,
output_format: str | None,
) -> None:
"""List sanitized binding metadata."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox = obj.connect_sandbox(sandbox_id)
try:
bindings = sandbox.credential_vault.list_bindings()
obj.output.print_models(
bindings,
columns=["name", "revision", "match", "auth"],
title="Credential Vault Bindings",
)
finally:
sandbox.close()
@binding_group.command("get")
@click.argument("sandbox_id")
@click.argument("binding_name")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def binding_get(
obj: ClientContext,
sandbox_id: str,
binding_name: str,
output_format: str | None,
) -> None:
"""Get sanitized metadata for one binding."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox = obj.connect_sandbox(sandbox_id)
try:
binding = sandbox.credential_vault.get_binding(binding_name)
obj.output.print_model(binding, title="Credential Vault Binding")
finally:
sandbox.close()
+125
View File
@@ -0,0 +1,125 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Experimental DevOps diagnostics commands: logs, inspect, events, summary."""
from __future__ import annotations
import click
from opensandbox_cli.client import ClientContext
from opensandbox_cli.utils import handle_errors, output_option, prepare_output
def _fetch_plain_text(obj: ClientContext, sandbox_id: str, endpoint: str, params: dict | None = None) -> str:
"""Fetch a diagnostics endpoint and return the plain-text body."""
sandbox_id = obj.resolve_sandbox_id(sandbox_id)
client = obj.get_devops_client()
resp = client.get(f"sandboxes/{sandbox_id}/diagnostics/{endpoint}", params=params)
if resp.status_code == 404:
raise click.ClickException(f"Sandbox '{sandbox_id}' not found.")
resp.raise_for_status()
return resp.text
@click.group("devops", invoke_without_command=True)
@click.pass_context
def devops_group(ctx: click.Context) -> None:
"""Experimental diagnostics for sandbox troubleshooting."""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
# ---- logs ----------------------------------------------------------------
@devops_group.command("logs")
@click.argument("sandbox_id")
@click.option("--tail", "-n", type=int, default=100, show_default=True, help="Number of trailing log lines.")
@click.option("--since", "-s", default=None, help="Only logs newer than this duration (e.g. 10m, 1h).")
@output_option("raw", help_text="Output format: raw.")
@click.pass_obj
@handle_errors
def devops_logs(
obj: ClientContext,
sandbox_id: str,
tail: int,
since: str | None,
output_format: str | None,
) -> None:
"""Deprecated: use `osb diagnostics logs` instead."""
click.echo("Warning: `osb devops logs` is deprecated. Use `osb diagnostics logs`.", err=True)
prepare_output(obj, output_format, allowed=("raw",), fallback="raw")
params: dict = {"tail": tail}
if since:
params["since"] = since
text = _fetch_plain_text(obj, sandbox_id, "logs", params=params)
click.echo(text)
# ---- inspect -------------------------------------------------------------
@devops_group.command("inspect")
@click.argument("sandbox_id")
@output_option("raw", help_text="Output format: raw.")
@click.pass_obj
@handle_errors
def devops_inspect(
obj: ClientContext, sandbox_id: str, output_format: str | None
) -> None:
"""Retrieve detailed container/pod inspection info."""
prepare_output(obj, output_format, allowed=("raw",), fallback="raw")
text = _fetch_plain_text(obj, sandbox_id, "inspect")
click.echo(text)
# ---- events --------------------------------------------------------------
@devops_group.command("events")
@click.argument("sandbox_id")
@click.option("--limit", "-l", type=int, default=50, show_default=True, help="Maximum number of events.")
@output_option("raw", help_text="Output format: raw.")
@click.pass_obj
@handle_errors
def devops_events(
obj: ClientContext, sandbox_id: str, limit: int, output_format: str | None
) -> None:
"""Deprecated: use `osb diagnostics events` instead."""
click.echo("Warning: `osb devops events` is deprecated. Use `osb diagnostics events`.", err=True)
prepare_output(obj, output_format, allowed=("raw",), fallback="raw")
params: dict = {"limit": limit}
text = _fetch_plain_text(obj, sandbox_id, "events", params=params)
click.echo(text)
# ---- summary -------------------------------------------------------------
@devops_group.command("summary")
@click.argument("sandbox_id")
@click.option("--tail", "-n", type=int, default=50, show_default=True, help="Number of trailing log lines.")
@click.option("--event-limit", type=int, default=20, show_default=True, help="Maximum number of events.")
@output_option("raw", help_text="Output format: raw.")
@click.pass_obj
@handle_errors
def devops_summary(
obj: ClientContext,
sandbox_id: str,
tail: int,
event_limit: int,
output_format: str | None,
) -> None:
"""One-shot diagnostics: inspect + events + logs combined."""
prepare_output(obj, output_format, allowed=("raw",), fallback="raw")
params: dict = {"tail": tail, "event_limit": event_limit}
text = _fetch_plain_text(obj, sandbox_id, "summary", params=params)
click.echo(text)
@@ -0,0 +1,129 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Stable sandbox diagnostics commands backed by the Python SDK."""
from __future__ import annotations
from typing import Any
import click
from opensandbox.models.diagnostics import DiagnosticContent
from opensandbox_cli.client import ClientContext
from opensandbox_cli.utils import handle_errors, output_option, prepare_output
def _diagnostic_to_dict(content: DiagnosticContent) -> dict[str, Any]:
"""Convert SDK diagnostics content to a CLI-friendly dict."""
return content.model_dump(mode="json")
def render_diagnostic_content(
obj: ClientContext,
content: DiagnosticContent,
output_format: str | None,
*,
title: str,
) -> None:
"""Render diagnostics descriptor or raw diagnostic payload."""
output = prepare_output(
obj,
output_format,
allowed=("table", "json", "yaml", "raw"),
fallback="raw",
)
if output.fmt == "raw":
if content.delivery == "inline":
click.echo(content.content or "")
return
if content.content_url:
click.echo(content.content_url)
return
raise click.ClickException("Diagnostic response did not include inline content or a content URL.")
output.print_dict(_diagnostic_to_dict(content), title=title)
@click.group("diagnostics", invoke_without_command=True)
@click.pass_context
def diagnostics_group(ctx: click.Context) -> None:
"""Stable sandbox diagnostics backed by the OpenSandbox SDK."""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
@diagnostics_group.command("logs")
@click.argument("sandbox_id")
@click.option(
"--scope",
"-s",
required=True,
help=(
"Diagnostic log scope. Common scopes: lifecycle for manager logs, "
"container for sandbox stdout; other scopes are server-defined."
),
)
@output_option(
"table",
"json",
"yaml",
"raw",
help_text="Output format. raw prints inline text, or the content URL for URL-delivered diagnostics.",
)
@click.pass_obj
@handle_errors
def diagnostics_logs(
obj: ClientContext,
sandbox_id: str,
scope: str,
output_format: str | None,
) -> None:
"""Retrieve diagnostic logs for a sandbox."""
sandbox_id = obj.resolve_sandbox_id(sandbox_id)
content = obj.get_manager().get_diagnostic_logs(sandbox_id, scope=scope)
render_diagnostic_content(obj, content, output_format, title="Diagnostic Logs")
@diagnostics_group.command("events")
@click.argument("sandbox_id")
@click.option(
"--scope",
"-s",
required=True,
help=(
"Diagnostic event scope. Common scopes: lifecycle for audit events, "
"runtime for scheduler/container events; other scopes are server-defined."
),
)
@output_option(
"table",
"json",
"yaml",
"raw",
help_text="Output format. raw prints inline text, or the content URL for URL-delivered diagnostics.",
)
@click.pass_obj
@handle_errors
def diagnostics_events(
obj: ClientContext,
sandbox_id: str,
scope: str,
output_format: str | None,
) -> None:
"""Retrieve diagnostic events for a sandbox."""
sandbox_id = obj.resolve_sandbox_id(sandbox_id)
content = obj.get_manager().get_diagnostic_events(sandbox_id, scope=scope)
render_diagnostic_content(obj, content, output_format, title="Diagnostic Events")
@@ -0,0 +1,98 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Runtime egress policy commands."""
from __future__ import annotations
import click
from opensandbox.models.sandboxes import NetworkRule
from opensandbox_cli.client import ClientContext
from opensandbox_cli.utils import handle_errors, output_option, prepare_output
def _parse_rule(value: str) -> NetworkRule:
"""Parse ACTION=TARGET into a NetworkRule."""
action, sep, target = value.partition("=")
if not sep:
raise click.BadParameter(
f"Invalid rule '{value}'. Use ACTION=TARGET, for example allow=pypi.org."
)
action = action.strip().lower()
target = target.strip()
if action not in ("allow", "deny"):
raise click.BadParameter(
f"Invalid rule action '{action}'. Use allow or deny."
)
return NetworkRule(action=action, target=target)
@click.group("egress", invoke_without_command=True)
@click.pass_context
def egress_group(ctx: click.Context) -> None:
"""Manage runtime egress policy for a sandbox."""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
@egress_group.command("get")
@click.argument("sandbox_id")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def egress_get(obj: ClientContext, sandbox_id: str, output_format: str | None) -> None:
"""Get the current egress policy."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox = obj.connect_sandbox(sandbox_id)
try:
policy = sandbox.get_egress_policy()
obj.output.print_model(policy, title="Egress Policy")
finally:
sandbox.close()
@egress_group.command("patch")
@click.argument("sandbox_id")
@click.option(
"--rule",
"rules",
multiple=True,
required=True,
help="Patch rule in ACTION=TARGET form. Repeatable, e.g. --rule allow=pypi.org.",
)
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def egress_patch(
obj: ClientContext,
sandbox_id: str,
rules: tuple[str, ...],
output_format: str | None,
) -> None:
"""Patch runtime egress rules with merge semantics."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
parsed_rules = [_parse_rule(rule) for rule in rules]
sandbox = obj.connect_sandbox(sandbox_id)
try:
sandbox.patch_egress_rules(parsed_rules)
obj.output.success_panel(
{
"sandbox_id": sandbox.id,
"patched_rules": [rule.model_dump(mode="json") for rule in parsed_rules],
},
title="Egress Policy Patched",
)
finally:
sandbox.close()
+442
View File
@@ -0,0 +1,442 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""File operation commands: cat, write, upload, download, rm, mv, mkdir, rmdir, search, info, chmod, replace."""
from __future__ import annotations
import sys
from pathlib import Path
import click
from opensandbox_cli.client import ClientContext
from opensandbox_cli.utils import handle_errors, output_option, prepare_output
def _parse_permission_mode(mode: str) -> int:
"""Parse a permission mode string like 644 or 755."""
try:
return int(mode)
except ValueError as exc:
raise click.BadParameter(
f"Invalid permission mode '{mode}'. Use a permission value like 644 or 755."
) from exc
@click.group("file", invoke_without_command=True)
@click.pass_context
def file_group(ctx: click.Context) -> None:
"""📁 File operations on a sandbox."""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
# ---- cat (read) -----------------------------------------------------------
@file_group.command("cat")
@click.argument("sandbox_id")
@click.argument("path")
@click.option("--encoding", default="utf-8", help="File encoding.")
@output_option("raw", help_text="Output format: raw.")
@click.pass_obj
@handle_errors
def file_cat(
obj: ClientContext,
sandbox_id: str,
path: str,
encoding: str,
output_format: str | None,
) -> None:
"""Read a file from the sandbox."""
prepare_output(obj, output_format, allowed=("raw",), fallback="raw")
sandbox = obj.connect_sandbox(sandbox_id)
try:
content = sandbox.files.read_file(path, encoding=encoding)
click.echo(content, nl=False)
finally:
sandbox.close()
# ---- write ----------------------------------------------------------------
@file_group.command("write")
@click.argument("sandbox_id")
@click.argument("path")
@click.option("--content", "-c", default=None, help="Content to write. Reads from stdin if not provided.")
@click.option("--encoding", default="utf-8", help="File encoding.")
@click.option("--mode", default=None, help="File permission mode (e.g. 0644).")
@click.option("--owner", default=None, help="File owner.")
@click.option("--group", default=None, help="File group.")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def file_write(
obj: ClientContext,
sandbox_id: str,
path: str,
content: str | None,
encoding: str,
mode: str | None,
owner: str | None,
group: str | None,
output_format: str | None,
) -> None:
"""Write content to a file in the sandbox."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
file_content = content
if file_content is None:
if sys.stdin.isatty():
click.echo("Reading from stdin (Ctrl+D to finish):", err=True)
file_content = sys.stdin.read()
sandbox = obj.connect_sandbox(sandbox_id)
try:
kwargs: dict = {"encoding": encoding}
if mode is not None:
kwargs["mode"] = _parse_permission_mode(mode)
if owner is not None:
kwargs["owner"] = owner
if group is not None:
kwargs["group"] = group
sandbox.files.write_file(path, file_content, **kwargs)
obj.output.success(f"Written: {path}")
finally:
sandbox.close()
# ---- upload ---------------------------------------------------------------
@file_group.command("upload")
@click.argument("sandbox_id")
@click.argument("local_path", type=click.Path(exists=True))
@click.argument("remote_path")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def file_upload(
obj: ClientContext,
sandbox_id: str,
local_path: str,
remote_path: str,
output_format: str | None,
) -> None:
"""Upload a local file to the sandbox."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox = obj.connect_sandbox(sandbox_id)
try:
with Path(local_path).open("rb") as data:
sandbox.files.write_file(remote_path, data)
obj.output.success(f"Uploaded: {local_path}{remote_path}")
finally:
sandbox.close()
# ---- download -------------------------------------------------------------
@file_group.command("download")
@click.argument("sandbox_id")
@click.argument("remote_path")
@click.argument("local_path", type=click.Path())
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def file_download(
obj: ClientContext,
sandbox_id: str,
remote_path: str,
local_path: str,
output_format: str | None,
) -> None:
"""Download a file from the sandbox to local disk."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox = obj.connect_sandbox(sandbox_id)
try:
destination = Path(local_path)
destination.parent.mkdir(parents=True, exist_ok=True)
with destination.open("wb") as out:
for chunk in sandbox.files.read_bytes_stream(remote_path):
out.write(chunk)
obj.output.success(f"Downloaded: {remote_path}{local_path}")
finally:
sandbox.close()
# ---- rm (delete) ----------------------------------------------------------
@file_group.command("rm")
@click.argument("sandbox_id")
@click.argument("paths", nargs=-1, required=True)
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def file_rm(
obj: ClientContext,
sandbox_id: str,
paths: tuple[str, ...],
output_format: str | None,
) -> None:
"""Delete files from the sandbox."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox = obj.connect_sandbox(sandbox_id)
try:
sandbox.files.delete_files(list(paths))
obj.output.print_rows(
[{"path": p, "status": "deleted"} for p in paths],
columns=["path", "status"],
title="Deleted Files",
)
finally:
sandbox.close()
# ---- mv (move) ------------------------------------------------------------
@file_group.command("mv")
@click.argument("sandbox_id")
@click.argument("source")
@click.argument("destination")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def file_mv(
obj: ClientContext,
sandbox_id: str,
source: str,
destination: str,
output_format: str | None,
) -> None:
"""Move/rename a file in the sandbox."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
from opensandbox.models.filesystem import MoveEntry
sandbox = obj.connect_sandbox(sandbox_id)
try:
sandbox.files.move_files([MoveEntry(source=source, destination=destination)])
obj.output.success(f"Moved: {source}{destination}")
finally:
sandbox.close()
# ---- mkdir ----------------------------------------------------------------
@file_group.command("mkdir")
@click.argument("sandbox_id")
@click.argument("paths", nargs=-1, required=True)
@click.option("--mode", default=None, help="Directory permission mode.")
@click.option("--owner", default=None, help="Directory owner.")
@click.option("--group", default=None, help="Directory group.")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def file_mkdir(
obj: ClientContext,
sandbox_id: str,
paths: tuple[str, ...],
mode: str | None,
owner: str | None,
group: str | None,
output_format: str | None,
) -> None:
"""Create directories in the sandbox."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
from opensandbox.models.filesystem import WriteEntry
sandbox = obj.connect_sandbox(sandbox_id)
try:
entries = []
for p in paths:
kwargs: dict = {"path": p}
if mode is not None:
kwargs["mode"] = _parse_permission_mode(mode)
if owner is not None:
kwargs["owner"] = owner
if group is not None:
kwargs["group"] = group
entries.append(WriteEntry(**kwargs))
sandbox.files.create_directories(entries)
obj.output.print_rows(
[{"path": p, "status": "created"} for p in paths],
columns=["path", "status"],
title="Created Directories",
)
finally:
sandbox.close()
# ---- rmdir ----------------------------------------------------------------
@file_group.command("rmdir")
@click.argument("sandbox_id")
@click.argument("paths", nargs=-1, required=True)
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def file_rmdir(
obj: ClientContext,
sandbox_id: str,
paths: tuple[str, ...],
output_format: str | None,
) -> None:
"""Delete directories from the sandbox."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox = obj.connect_sandbox(sandbox_id)
try:
sandbox.files.delete_directories(list(paths))
obj.output.print_rows(
[{"path": p, "status": "removed"} for p in paths],
columns=["path", "status"],
title="Removed Directories",
)
finally:
sandbox.close()
# ---- search ---------------------------------------------------------------
@file_group.command("search")
@click.argument("sandbox_id")
@click.argument("path")
@click.option("--pattern", "-p", required=True, help="Glob pattern to search for.")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def file_search(
obj: ClientContext,
sandbox_id: str,
path: str,
pattern: str,
output_format: str | None,
) -> None:
"""Search for files in the sandbox."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
from opensandbox.models.filesystem import SearchEntry
sandbox = obj.connect_sandbox(sandbox_id)
try:
results = sandbox.files.search(SearchEntry(path=path, pattern=pattern))
if not results:
if obj.output.fmt in ("json", "yaml"):
obj.output.print_models([], columns=[])
else:
obj.output.info("No files found.")
return
if obj.output.fmt in ("json", "yaml"):
obj.output.print_models(results, columns=["path", "size", "mode", "owner", "modified_at"])
else:
obj.output.print_models(results, columns=["path", "size", "owner"], title="Search Results")
finally:
sandbox.close()
# ---- info (stat) ----------------------------------------------------------
@file_group.command("info")
@click.argument("sandbox_id")
@click.argument("paths", nargs=-1, required=True)
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def file_info(
obj: ClientContext,
sandbox_id: str,
paths: tuple[str, ...],
output_format: str | None,
) -> None:
"""Get file/directory info."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox = obj.connect_sandbox(sandbox_id)
try:
info_map = sandbox.files.get_file_info(list(paths))
rows = [{"path": path, **entry.model_dump(mode="json")} for path, entry in info_map.items()]
obj.output.print_rows(
rows,
columns=["path", "size", "mode", "owner", "group", "created_at", "modified_at"],
title="Path Info",
)
finally:
sandbox.close()
# ---- chmod ----------------------------------------------------------------
@file_group.command("chmod")
@click.argument("sandbox_id")
@click.argument("path")
@click.option("--mode", required=True, help="Permission mode (e.g. 0755).")
@click.option("--owner", default=None, help="File owner.")
@click.option("--group", default=None, help="File group.")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def file_chmod(
obj: ClientContext,
sandbox_id: str,
path: str,
mode: str,
owner: str | None,
group: str | None,
output_format: str | None,
) -> None:
"""Set file permissions."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
from opensandbox.models.filesystem import SetPermissionEntry
sandbox = obj.connect_sandbox(sandbox_id)
try:
sandbox.files.set_permissions(
[
SetPermissionEntry(
path=path,
mode=_parse_permission_mode(mode),
owner=owner,
group=group,
)
]
)
obj.output.success(f"Permissions set: {path}")
finally:
sandbox.close()
# ---- replace --------------------------------------------------------------
@file_group.command("replace")
@click.argument("sandbox_id")
@click.argument("path")
@click.option("--old", required=True, help="Text to search for.")
@click.option("--new", required=True, help="Replacement text.")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def file_replace(
obj: ClientContext,
sandbox_id: str,
path: str,
old: str,
new: str,
output_format: str | None,
) -> None:
"""Replace content in a file."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
from opensandbox.models.filesystem import ContentReplaceEntry
sandbox = obj.connect_sandbox(sandbox_id)
try:
sandbox.files.replace_contents(
[ContentReplaceEntry(path=path, old_content=old, new_content=new)]
)
obj.output.success(f"Replaced in: {path}")
finally:
sandbox.close()
+594
View File
@@ -0,0 +1,594 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Sandbox lifecycle commands: create, list, get, kill, pause, resume, renew, endpoint, health, metrics."""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from typing import Any
import click
from opensandbox.models.sandboxes import (
CredentialProxyConfig,
NetworkPolicy,
SandboxFilter,
SandboxImageAuth,
SandboxImageSpec,
SandboxMetrics,
SandboxState,
Volume,
)
from opensandbox_cli.client import ClientContext
from opensandbox_cli.utils import (
DURATION,
KEY_VALUE,
handle_errors,
output_option,
parse_nullable_duration,
prepare_output,
)
@click.group("sandbox", invoke_without_command=True)
@click.pass_context
def sandbox_group(ctx: click.Context) -> None:
"""📦 Manage sandbox lifecycle."""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
# Alias: osb sb ...
sandbox_group.name = "sandbox"
_SANDBOX_STATE_CANONICAL = {
state.lower(): state for state in SandboxState.values()
}
def _normalize_sandbox_states(states: tuple[str, ...]) -> list[str] | None:
"""Normalize case-insensitive CLI state filters to SDK canonical values."""
if not states:
return None
normalized: list[str] = []
for state in states:
canonical = _SANDBOX_STATE_CANONICAL.get(state.strip().lower())
if canonical is None:
choices = ", ".join(sorted(SandboxState.values()))
raise click.ClickException(
f"Invalid sandbox state '{state}'. Valid values: {choices}"
)
normalized.append(canonical)
return normalized
# ---- create ---------------------------------------------------------------
@sandbox_group.command("create")
@click.option("--image", "-i", required=False, help="Container image (e.g. python:3.11). Defaults to config value if set.")
@click.option(
"--image-auth-username",
default=None,
help="Registry username for pulling a private image.",
)
@click.option(
"--image-auth-password",
default=None,
help="Registry password or token for pulling a private image.",
)
@click.option(
"--timeout",
"-t",
"timeout_raw",
default=None,
help="Sandbox lifetime (e.g. 10m, 1h), 'none' for manual cleanup, or omit to use defaults.timeout / SDK default TTL.",
)
@click.option("--env", "-e", "envs", multiple=True, type=KEY_VALUE, help="Environment variable (KEY=VALUE). Repeatable.")
@click.option("--metadata", "-m", "metadata_kv", multiple=True, type=KEY_VALUE, help="Metadata (KEY=VALUE). Repeatable.")
@click.option("--extension", "extensions_kv", multiple=True, type=KEY_VALUE, help="Extension parameter (KEY=VALUE). Repeatable.")
@click.option("--resource", "resources_kv", multiple=True, type=KEY_VALUE, help="Resource limit (e.g. cpu=1 memory=2Gi). Repeatable.")
@click.option(
"--entrypoint",
"entrypoint",
multiple=True,
help="Entrypoint argv item. Repeat to build the full entrypoint.",
)
@click.option("--network-policy-file", type=click.Path(exists=True), default=None, help="Network policy JSON file.")
@click.option(
"--credential-proxy",
is_flag=True,
default=False,
help="Enable Credential Vault transparent proxy support. Requires --network-policy-file.",
)
@click.option("--volumes-file", type=click.Path(exists=True), default=None, help="Volumes JSON file (list of volume objects).")
@click.option("--skip-health-check", is_flag=True, default=False, help="Skip waiting for sandbox readiness.")
@click.option("--ready-timeout", type=DURATION, default=None, help="Max wait time for sandbox readiness (e.g. 30s).")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def sandbox_create(
obj: ClientContext,
image: str | None,
image_auth_username: str | None,
image_auth_password: str | None,
timeout_raw: str | None,
envs: tuple[tuple[str, str], ...],
metadata_kv: tuple[tuple[str, str], ...],
extensions_kv: tuple[tuple[str, str], ...],
resources_kv: tuple[tuple[str, str], ...],
entrypoint: tuple[str, ...],
network_policy_file: str | None,
credential_proxy: bool,
volumes_file: str | None,
skip_health_check: bool,
ready_timeout: timedelta | None,
output_format: str | None,
) -> None:
"""Create a new sandbox."""
from opensandbox.sync.sandbox import SandboxSync
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
if image is None:
image = obj.resolved_config.get("default_image")
if not image:
raise click.ClickException(
"Sandbox image is required. Pass --image or set defaults.image in the CLI config."
)
if bool(image_auth_username) != bool(image_auth_password):
raise click.ClickException(
"Pass both --image-auth-username and --image-auth-password together."
)
if credential_proxy and not network_policy_file:
raise click.ClickException(
"--credential-proxy requires --network-policy-file because Credential Vault injection needs egress policy."
)
timeout: timedelta | None
timeout_is_set = False
if timeout_raw is not None:
timeout = parse_nullable_duration(timeout_raw)
timeout_is_set = True
else:
timeout = None
if timeout_raw is None:
default_timeout = obj.resolved_config.get("default_timeout")
if default_timeout:
timeout = parse_nullable_duration(default_timeout)
timeout_is_set = True
image_spec: SandboxImageSpec | str = image
if image_auth_username and image_auth_password:
image_spec = SandboxImageSpec(
image=image,
auth=SandboxImageAuth(
username=image_auth_username,
password=image_auth_password,
),
)
kwargs: dict = {
"connection_config": obj.connection_config,
"skip_health_check": skip_health_check,
}
if timeout_is_set:
kwargs["timeout"] = timeout
if ready_timeout is not None:
kwargs["ready_timeout"] = ready_timeout
if envs:
kwargs["env"] = dict(envs)
if metadata_kv:
kwargs["metadata"] = dict(metadata_kv)
if extensions_kv:
kwargs["extensions"] = dict(extensions_kv)
if resources_kv:
kwargs["resource"] = dict(resources_kv)
if entrypoint:
kwargs["entrypoint"] = list(entrypoint)
if network_policy_file:
with open(network_policy_file) as f:
kwargs["network_policy"] = NetworkPolicy(**json.load(f))
if credential_proxy:
kwargs["credential_proxy"] = CredentialProxyConfig(enabled=True)
if volumes_file:
with open(volumes_file) as f:
raw_volumes = json.load(f)
if not isinstance(raw_volumes, list):
raise click.ClickException(
f"Volumes file must contain a JSON array, got {type(raw_volumes).__name__}."
)
kwargs["volumes"] = [Volume(**item) for item in raw_volumes]
with obj.output.spinner("Creating sandbox..."):
sandbox = SandboxSync.create(image_spec, **kwargs)
obj.output.success_panel(
{
"id": sandbox.id,
"image": image,
"status": "created",
"timeout": _describe_create_timeout(timeout_is_set, timeout),
},
title="Sandbox Created",
)
# ---- list -----------------------------------------------------------------
@sandbox_group.command("list")
@click.option("--state", "-s", "states", multiple=True, help="Filter by state (Pending, Running, Paused, ...). Repeatable.")
@click.option("--metadata", "-m", "metadata_kv", multiple=True, type=KEY_VALUE, help="Metadata filter (KEY=VALUE). Repeatable.")
@click.option("--page", type=click.IntRange(min=1), default=None, help="Page number (1-indexed).")
@click.option("--page-size", type=click.IntRange(min=1), default=None, help="Items per page.")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def sandbox_list(
obj: ClientContext,
states: tuple[str, ...],
metadata_kv: tuple[tuple[str, str], ...],
page: int | None,
page_size: int | None,
output_format: str | None,
) -> None:
"""List sandboxes."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
mgr = obj.get_manager()
filt = SandboxFilter(
states=_normalize_sandbox_states(states),
metadata=dict(metadata_kv) if metadata_kv else None,
page=page,
page_size=page_size,
)
with obj.output.spinner("Fetching sandboxes..."):
result = mgr.list_sandbox_infos(filt)
if not result.sandbox_infos:
if obj.output.fmt in ("json", "yaml"):
obj.output.print_dict(
{
"items": [],
"pagination": result.pagination.model_dump(mode="json"),
},
title="Sandboxes",
)
else:
obj.output.info("No sandboxes found.")
return
raw_rows = [info.model_dump(mode="json") for info in result.sandbox_infos]
# For machine-readable formats, preserve the original structure
if obj.output.fmt in ("json", "yaml"):
obj.output.print_dict(
{
"items": raw_rows,
"pagination": result.pagination.model_dump(mode="json"),
},
title="Sandboxes",
)
return
# Flatten nested status/image objects for clean table display
rows = []
for d in raw_rows:
flat = dict(d)
status_val = flat.get("status")
if isinstance(status_val, dict):
flat["status"] = status_val.get("state", str(status_val))
image_val = flat.get("image")
if isinstance(image_val, dict):
flat["image"] = image_val.get("image", str(image_val))
rows.append(flat)
obj.output.print_rows(
rows,
columns=["id", "status", "image", "created_at", "expires_at"],
title="Sandboxes",
)
# ---- get ------------------------------------------------------------------
@sandbox_group.command("get")
@click.argument("sandbox_id")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def sandbox_get(obj: ClientContext, sandbox_id: str, output_format: str | None) -> None:
"""Get sandbox details."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox_id = obj.resolve_sandbox_id(sandbox_id)
mgr = obj.get_manager()
info = mgr.get_sandbox_info(sandbox_id)
d = info.model_dump(mode="json")
# For machine-readable formats, preserve the original structure
if obj.output.fmt in ("json", "yaml"):
obj.output.print_dict(d, title="Sandbox Info")
return
# Flatten nested objects for clean table display
status_val = d.get("status")
if isinstance(status_val, dict):
d["status"] = status_val.get("state", str(status_val))
if status_val.get("reason"):
d["status_reason"] = status_val["reason"]
if status_val.get("message"):
d["status_message"] = status_val["message"]
image_val = d.get("image")
if isinstance(image_val, dict):
d["image"] = image_val.get("image", str(image_val))
obj.output.print_dict(d, title="Sandbox Info")
# ---- kill -----------------------------------------------------------------
@sandbox_group.command("kill")
@click.argument("sandbox_ids", nargs=-1, required=True)
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def sandbox_kill(
obj: ClientContext, sandbox_ids: tuple[str, ...], output_format: str | None
) -> None:
"""Terminate one or more sandboxes."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
mgr = obj.get_manager()
rows: list[dict[str, str]] = []
for sid in sandbox_ids:
resolved = obj.resolve_sandbox_id(sid)
with obj.output.spinner(f"Killing sandbox {resolved}..."):
mgr.kill_sandbox(resolved)
rows.append({"sandbox_id": resolved, "status": "terminated"})
obj.output.print_rows(rows, columns=["sandbox_id", "status"], title="Sandboxes")
# ---- pause ----------------------------------------------------------------
@sandbox_group.command("pause")
@click.argument("sandbox_id")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def sandbox_pause(obj: ClientContext, sandbox_id: str, output_format: str | None) -> None:
"""Pause a running sandbox."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox_id = obj.resolve_sandbox_id(sandbox_id)
mgr = obj.get_manager()
with obj.output.spinner("Pausing sandbox..."):
mgr.pause_sandbox(sandbox_id)
obj.output.success(f"Sandbox paused: {sandbox_id}")
# ---- resume ---------------------------------------------------------------
@sandbox_group.command("resume")
@click.argument("sandbox_id")
@click.option("--skip-health-check", is_flag=True, default=False, help="Skip waiting for sandbox readiness after resume.")
@click.option("--resume-timeout", type=DURATION, default=None, help="Max wait time for sandbox readiness after resume (e.g. 30s).")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def sandbox_resume(
obj: ClientContext,
sandbox_id: str,
skip_health_check: bool,
resume_timeout: timedelta | None,
output_format: str | None,
) -> None:
"""Resume a paused sandbox."""
from opensandbox.sync.sandbox import SandboxSync
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox_id = obj.resolve_sandbox_id(sandbox_id)
sandbox = None
try:
kwargs = {
"connection_config": obj.connection_config,
"skip_health_check": skip_health_check,
}
if resume_timeout is not None:
kwargs["resume_timeout"] = resume_timeout
with obj.output.spinner("Resuming sandbox..."):
sandbox = SandboxSync.resume(sandbox_id, **kwargs)
obj.output.success(f"Sandbox resumed: {sandbox_id}")
finally:
if sandbox is not None:
sandbox.close()
# ---- renew ----------------------------------------------------------------
@sandbox_group.command("renew")
@click.argument("sandbox_id")
@click.option("--timeout", "-t", required=True, type=DURATION, help="New TTL duration (e.g. 30m, 2h).")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def sandbox_renew(
obj: ClientContext,
sandbox_id: str,
timeout: timedelta,
output_format: str | None,
) -> None:
"""Renew sandbox expiration."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox_id = obj.resolve_sandbox_id(sandbox_id)
mgr = obj.get_manager()
with obj.output.spinner("Renewing sandbox..."):
resp = mgr.renew_sandbox(sandbox_id, timeout)
obj.output.success_panel(
{"sandbox_id": sandbox_id, "expires_at": str(resp.expires_at)},
title="Sandbox Renewed",
)
# ---- endpoint -------------------------------------------------------------
@sandbox_group.command("endpoint")
@click.argument("sandbox_id")
@click.option("--port", "-p", required=True, type=click.IntRange(min=1, max=65535), help="Port number.")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def sandbox_endpoint(
obj: ClientContext, sandbox_id: str, port: int, output_format: str | None
) -> None:
"""Get the public endpoint for a sandbox port."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox = obj.connect_sandbox(sandbox_id)
try:
ep = sandbox.get_endpoint(port)
obj.output.print_model(ep, title="Sandbox Endpoint")
finally:
sandbox.close()
# ---- health ---------------------------------------------------------------
@sandbox_group.command("health")
@click.argument("sandbox_id")
@output_option("table", "json", "yaml")
@click.pass_obj
@handle_errors
def sandbox_health(
obj: ClientContext, sandbox_id: str, output_format: str | None
) -> None:
"""Check sandbox health."""
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
sandbox = obj.connect_sandbox(sandbox_id)
try:
healthy = sandbox.is_healthy()
if obj.output.fmt == "table":
if healthy:
obj.output.success(f"Sandbox {sandbox_id} is healthy")
else:
obj.output.error(f"Sandbox {sandbox_id} is unhealthy")
else:
obj.output.print_dict(
{"sandbox_id": sandbox_id, "healthy": healthy},
title="Health Check",
)
finally:
sandbox.close()
# ---- metrics --------------------------------------------------------------
@sandbox_group.command("metrics")
@click.argument("sandbox_id")
@click.option("--watch", is_flag=True, default=False, help="Stream metrics updates in real time.")
@output_option("table", "json", "yaml", "raw")
@click.pass_obj
@handle_errors
def sandbox_metrics(
obj: ClientContext,
sandbox_id: str,
watch: bool,
output_format: str | None,
) -> None:
"""Get sandbox resource metrics."""
fallback = "raw" if watch else "table"
prepare_output(
obj, output_format, allowed=("table", "json", "yaml", "raw"), fallback=fallback
)
sandbox = obj.connect_sandbox(sandbox_id)
try:
if watch:
_watch_sandbox_metrics(obj, sandbox)
return
m = sandbox.get_metrics()
obj.output.print_model(m, title="Sandbox Metrics")
finally:
sandbox.close()
def _watch_sandbox_metrics(obj: ClientContext, sandbox) -> None: # type: ignore[no-untyped-def]
"""Stream sandbox metrics from the execd SSE endpoint."""
client = getattr(sandbox.metrics, "_httpx_client", None)
if client is None:
raise click.ClickException("Streaming metrics are unavailable for this sandbox connection.")
headers = {"Accept": "text/event-stream"}
try:
with client.stream("GET", "/metrics/watch", headers=headers) as response:
response.raise_for_status()
for line in response.iter_lines():
metric, warning = _parse_metric_stream_line(line)
if warning:
obj.output.warning(warning)
continue
if metric is None:
continue
_render_stream_metric(obj, metric)
except KeyboardInterrupt:
return
def _parse_metric_stream_line(line: str) -> tuple[SandboxMetrics | None, str | None]:
"""Parse one line from the metrics SSE stream."""
stripped = line.strip()
if not stripped or stripped.startswith((":","event:", "id:", "retry:")):
return None, None
payload = stripped[5:].strip() if stripped.startswith("data:") else stripped
if not payload:
return None, None
decoded: Any = json.loads(payload)
if isinstance(decoded, dict) and decoded.get("error"):
return None, f"Metrics stream error: {decoded['error']}"
return SandboxMetrics.model_validate(decoded), None
def _describe_create_timeout(
timeout_is_set: bool, timeout: timedelta | None
) -> str:
"""Describe the sandbox timeout mode shown in create output."""
if not timeout_is_set:
return "sdk-default"
if timeout is None:
return "manual-cleanup"
return str(timeout)
def _render_stream_metric(obj: ClientContext, metric: SandboxMetrics) -> None:
"""Render one streaming metrics sample."""
if obj.output.fmt in ("table", "raw"):
timestamp = datetime.fromtimestamp(metric.timestamp / 1000, tz=timezone.utc).isoformat()
click.echo(
" ".join(
[
f"[{timestamp}]",
f"cpu={metric.cpu_used_percentage:.2f}%",
f"cores={metric.cpu_count:g}",
f"mem={metric.memory_used_in_mib:.2f}/{metric.memory_total_in_mib:.2f}MiB",
]
)
)
return
if obj.output.fmt == "json":
click.echo(json.dumps(metric.model_dump(mode="json"), default=str))
return
if obj.output.fmt == "yaml":
obj.output.print_model(metric)
return
+775
View File
@@ -0,0 +1,775 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Install built-in OpenSandbox AI skills/rules for coding tools."""
from __future__ import annotations
import json
from dataclasses import asdict
from pathlib import Path
from typing import Literal, TypedDict, cast
import click
from opensandbox_cli.client import ClientContext
from opensandbox_cli.output import OutputFormatter
from opensandbox_cli.skill_registry import (
BUILTIN_SKILLS,
DEFAULT_SKILL,
SkillSpec,
extract_section,
get_builtin_skill,
get_builtin_skill_source,
list_builtin_skills,
read_skill_markdown,
render_skill_for_target,
split_frontmatter,
)
from opensandbox_cli.utils import handle_errors, output_option, prepare_output
class CopyScopeConfig(TypedDict):
strategy: Literal["copy"]
dest_dir: Path
preserve_frontmatter: bool
file_suffix: str | None
dest_file_template: str | None
class AppendScopeConfig(TypedDict):
strategy: Literal["append"]
dest_file: Path
preserve_frontmatter: bool
TargetScopeConfig = CopyScopeConfig | AppendScopeConfig
class TargetConfig(TypedDict):
label: str
scopes: dict[str, TargetScopeConfig]
_TARGETS = cast(dict[str, TargetConfig], {
"claude": {
"label": "Claude Code",
"scopes": {
"project": {
"strategy": "copy",
"dest_dir": Path(".claude") / "skills",
"preserve_frontmatter": True,
},
"global": {
"strategy": "copy",
"dest_dir": Path.home() / ".claude" / "skills",
"preserve_frontmatter": True,
}
},
},
"cursor": {
"label": "Cursor",
"scopes": {
"project": {
"strategy": "copy",
"dest_dir": Path(".cursor") / "rules",
"preserve_frontmatter": False,
"file_suffix": ".mdc",
},
"global": {
"strategy": "copy",
"dest_dir": Path.home() / ".cursor" / "rules",
"preserve_frontmatter": False,
"file_suffix": ".mdc",
}
},
},
"codex": {
"label": "Codex",
"scopes": {
"project": {
"strategy": "copy",
"dest_dir": Path(".codex") / "skills",
"dest_file_template": "{slug}/SKILL.md",
"preserve_frontmatter": True,
},
"global": {
"strategy": "copy",
"dest_dir": Path.home() / ".codex" / "skills",
"dest_file_template": "{slug}/SKILL.md",
"preserve_frontmatter": True,
},
},
},
"copilot": {
"label": "GitHub Copilot",
"scopes": {
"project": {
"strategy": "append",
"dest_file": Path(".github") / "copilot-instructions.md",
"preserve_frontmatter": False,
},
"global": {
"strategy": "append",
"dest_file": Path.home() / ".github" / "copilot-instructions.md",
"preserve_frontmatter": False,
}
},
},
"windsurf": {
"label": "Windsurf",
"scopes": {
"project": {
"strategy": "append",
"dest_file": Path(".windsurfrules"),
"preserve_frontmatter": False,
},
"global": {
"strategy": "append",
"dest_file": Path.home() / ".windsurfrules",
"preserve_frontmatter": False,
}
},
},
"cline": {
"label": "Cline",
"scopes": {
"project": {
"strategy": "append",
"dest_file": Path(".clinerules"),
"preserve_frontmatter": False,
},
"global": {
"strategy": "append",
"dest_file": Path.home() / ".clinerules",
"preserve_frontmatter": False,
}
},
},
"opencode": {
"label": "OpenCode",
"scopes": {
"project": {
"strategy": "copy",
"dest_dir": Path(".agents") / "skills",
"dest_file_template": "{slug}/SKILL.md",
"preserve_frontmatter": True,
},
"global": {
"strategy": "copy",
"dest_dir": Path.home() / ".agents" / "skills",
"dest_file_template": "{slug}/SKILL.md",
"preserve_frontmatter": True,
},
},
},
})
_ALL_TARGET_NAMES = list(_TARGETS.keys())
_ALL_SKILL_NAMES = list(BUILTIN_SKILLS.keys())
_ALL_SCOPE_NAMES = ["project", "global"]
_SKILL_AREAS = {
"sandbox-lifecycle": "Lifecycle",
"command-execution": "Execution",
"file-operations": "Files",
"network-egress": "Network",
"sandbox-troubleshooting": "Troubleshooting",
}
class InstallResult(TypedDict):
skill: str
target: str
target_label: str
scope: str
path: str
status: Literal["installed", "updated", "already_present"]
requires_restart: bool
class UninstallResult(TypedDict):
skill: str
target: str
target_label: str
scope: str
path: str
status: Literal["removed", "not_installed"]
requires_restart: bool
def _marker_begin(skill: SkillSpec) -> str:
return f"<!-- BEGIN {skill.marker_id} -->"
def _marker_end(skill: SkillSpec) -> str:
return f"<!-- END {skill.marker_id} -->"
def _get_scope_cfg(name: str, scope: str) -> TargetScopeConfig:
target_cfg = _TARGETS[name]
scopes = target_cfg["scopes"]
return scopes[scope]
def _target_layout_summary(name: str, scope: str) -> str:
cfg = _get_scope_cfg(name, scope)
if cfg["strategy"] == "append":
return f"aggregate into one instructions file at {cfg['dest_file']}"
dest_dir = cfg["dest_dir"]
template = cfg.get("dest_file_template")
if template:
sample_path = dest_dir / template.format(slug="<skill-name>")
return f"install one file per skill under {sample_path}"
suffix = cfg.get("file_suffix") or ".md"
sample_path = dest_dir / f"<skill-name>{suffix}"
return f"install one file per skill under {sample_path}"
def _target_destination(name: str, scope: str, skill: SkillSpec) -> Path:
cfg = _get_scope_cfg(name, scope)
if cfg["strategy"] == "copy":
dest_dir = cfg["dest_dir"]
template = cfg.get("dest_file_template") or ""
if template:
return dest_dir / template.format(slug=skill.slug)
suffix = cfg.get("file_suffix") or ".md"
return dest_dir / f"{skill.slug}{suffix}"
return cfg["dest_file"]
def _render_for_target(name: str, scope: str, skill: SkillSpec) -> str:
cfg = _get_scope_cfg(name, scope)
markdown = read_skill_markdown(skill)
preserve_frontmatter = bool(cfg.get("preserve_frontmatter", False))
return render_skill_for_target(
skill,
markdown,
preserve_frontmatter=preserve_frontmatter,
)
def _get_output_formatter() -> OutputFormatter | None:
ctx = click.get_current_context(silent=True)
obj = getattr(ctx, "obj", None) if ctx else None
output = getattr(obj, "output", None) if obj else None
return output if isinstance(output, OutputFormatter) else None
def _prepare_skills_output(output_format: str | None) -> None:
ctx = click.get_current_context(silent=True)
obj = getattr(ctx, "obj", None) if ctx else None
if isinstance(obj, ClientContext):
prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table")
return
existing = getattr(obj, "output", None) if obj is not None else None
if output_format is None and isinstance(existing, OutputFormatter):
return
fmt = output_format or "table"
formatter = OutputFormatter(fmt, color=False)
if obj is not None:
obj.output = formatter
def _emit_output(
*,
table_renderer,
data: object,
) -> None:
output = _get_output_formatter()
if output is None or output.fmt == "table":
table_renderer()
return
if output.fmt == "json":
click.echo(json.dumps(data, indent=2, default=str))
return
output._print_yaml(data)
def _remove_marked_block(existing: str, skill: SkillSpec) -> str:
begin = _marker_begin(skill)
end = _marker_end(skill)
if begin not in existing or end not in existing:
return existing
start = existing.index(begin)
finish = existing.index(end) + len(end)
before = existing[:start].rstrip("\n")
after = existing[finish:].lstrip("\n")
if before and after:
return before + "\n\n" + after
return before or after
def _is_installed(name: str, scope: str, skill: SkillSpec) -> bool:
dest = _target_destination(name, scope, skill)
if not dest.exists():
return False
cfg = _get_scope_cfg(name, scope)
if cfg["strategy"] == "copy":
return True
content = dest.read_text(encoding="utf-8")
return _marker_begin(skill) in content and _marker_end(skill) in content
def _build_marked_block(skill: SkillSpec, content: str) -> str:
return (
f"{_marker_begin(skill)}\n"
f"{content.strip()}\n"
f"{_marker_end(skill)}\n"
)
def _install_copy(name: str, scope: str, skill: SkillSpec, content: str) -> tuple[str, Path]:
dest = _target_destination(name, scope, skill)
if not dest.exists():
status = "installed"
else:
existing = dest.read_text(encoding="utf-8")
status = "already_present" if existing == content else "updated"
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(content, encoding="utf-8")
return status, dest
def _install_append(name: str, scope: str, skill: SkillSpec, content: str) -> tuple[str, Path]:
dest = _target_destination(name, scope, skill)
dest.parent.mkdir(parents=True, exist_ok=True)
existing = dest.read_text(encoding="utf-8") if dest.exists() else ""
cleaned = _remove_marked_block(existing, skill).rstrip("\n")
marked_block = _build_marked_block(skill, content)
new_content = f"{cleaned}\n\n{marked_block}" if cleaned else marked_block
if not existing:
status = "installed"
elif new_content == existing:
status = "already_present"
else:
status = "updated" if _is_installed(name, scope, skill) else "installed"
dest.write_text(new_content, encoding="utf-8")
return status, dest
def _install_target(name: str, scope: str, skill: SkillSpec) -> tuple[str, Path]:
content = _render_for_target(name, scope, skill)
cfg = _get_scope_cfg(name, scope)
if cfg["strategy"] == "copy":
return _install_copy(name, scope, skill, content)
return _install_append(name, scope, skill, content)
def _uninstall_target(name: str, scope: str, skill: SkillSpec) -> tuple[bool, Path]:
dest = _target_destination(name, scope, skill)
if not dest.exists():
return False, dest
cfg = _get_scope_cfg(name, scope)
if cfg["strategy"] == "copy":
dest.unlink()
if dest.parent.exists() and not any(dest.parent.iterdir()):
dest.parent.rmdir()
return True, dest
existing = dest.read_text(encoding="utf-8")
cleaned = _remove_marked_block(existing, skill)
if cleaned == existing:
return False, dest
if cleaned.strip():
dest.write_text(cleaned.rstrip("\n") + "\n", encoding="utf-8")
else:
dest.unlink()
return True, dest
def _resolve_skills(skill_name: str | None, install_all_builtins: bool) -> list[SkillSpec]:
if install_all_builtins:
return list_builtin_skills()
if not skill_name:
raise click.UsageError("A skill name is required unless --all-builtins is used.")
return [get_builtin_skill(skill_name)]
def _install_guidance_text() -> str:
return (
"Install guidance:\n\n"
" Discover bundled skills first:\n"
" osb skills list\n"
" osb skills show <skill-name>\n\n"
" Install one skill for one tool:\n"
" osb skills install <skill-name> --target <tool> --scope <scope>\n\n"
" Install all bundled skills for one tool:\n"
" osb skills install --all-builtins --target <tool> --scope <scope>\n\n"
" Discover skills and targets:\n"
" osb skills list\n"
" osb skills show <skill-name>\n\n"
f" Available skills: {', '.join(_ALL_SKILL_NAMES)}\n"
f" Available targets: {', '.join(_ALL_TARGET_NAMES)}\n"
f" Available scopes: {', '.join(_ALL_SCOPE_NAMES)}"
)
@click.group("skills", invoke_without_command=True)
@click.pass_context
def skills_group(ctx: click.Context) -> None:
"""Manage bundled OpenSandbox skills for AI coding tools.
Discover with `osb skills list`, inspect with `osb skills show <skill>`,
then install non-interactively with
`osb skills install <skill> --target codex --scope project`.
"""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
@skills_group.command("install")
@click.argument(
"skill_name",
required=False,
type=click.Choice(_ALL_SKILL_NAMES, case_sensitive=False),
)
@click.option(
"--all-builtins",
is_flag=True,
default=False,
help="Install all bundled skills instead of a single skill.",
)
@click.option(
"--target",
"-t",
type=click.Choice(_ALL_TARGET_NAMES + ["all"], case_sensitive=False),
default=None,
help="Target AI tool to install the skill for.",
)
@click.option(
"--scope",
type=click.Choice(_ALL_SCOPE_NAMES, case_sensitive=False),
default=None,
help="Install scope for targets that support multiple locations.",
)
@click.option(
"--force",
"-f",
is_flag=True,
default=False,
help="Accepted for compatibility. Installs are already non-interactive and idempotent.",
)
@output_option("table", "json", "yaml")
@handle_errors
def skills_install(
skill_name: str | None,
all_builtins: bool,
target: str | None,
scope: str | None,
force: bool,
output_format: str | None,
) -> None:
"""Install one or more bundled OpenSandbox skills.
This command is non-interactive and idempotent. Re-running an install will
report `already_present` or `updated` instead of prompting.
"""
_prepare_skills_output(output_format)
if all_builtins and skill_name:
raise click.UsageError("Pass either a skill name or --all-builtins, not both.")
if target is None:
raise click.UsageError(
"Missing required option '--target'.\n\n" + _install_guidance_text()
)
if scope is None:
raise click.UsageError(
"Missing required option '--scope'.\n\n" + _install_guidance_text()
)
if not all_builtins and skill_name is None:
raise click.UsageError(
"A skill name is required unless --all-builtins is used.\n\n" + _install_guidance_text()
)
_ = force
skills = _resolve_skills(skill_name, all_builtins)
targets = _ALL_TARGET_NAMES if target == "all" else [target]
results: list[InstallResult] = []
for skill in skills:
for target_name in targets:
label = str(_TARGETS[target_name]["label"])
status, installed_path = _install_target(target_name, scope, skill)
results.append(
{
"skill": skill.slug,
"target": target_name,
"target_label": label,
"scope": scope,
"path": str(installed_path),
"status": cast(Literal["installed", "updated", "already_present"], status),
"requires_restart": True,
}
)
def _render_table() -> None:
click.echo("Install plan:\n")
for target_name in targets:
label = str(_TARGETS[target_name]["label"])
click.echo(f" {label} [{scope}]: {_target_layout_summary(target_name, scope)}")
click.echo()
for result in results:
click.echo(
f" {result['status']:<15} "
f"{result['target_label']} [{result['scope']}]: "
f"{result['skill']} -> {result['path']}"
)
click.echo()
click.echo("Done! Restart your AI coding tool to pick up the updated skill set.")
_emit_output(
table_renderer=_render_table,
data={
"operations": results,
"requires_restart": True,
},
)
@skills_group.command("show")
@click.argument(
"skill_name",
type=click.Choice(_ALL_SKILL_NAMES, case_sensitive=False),
)
@output_option("table", "json", "yaml")
@handle_errors
def skills_show(skill_name: str, output_format: str | None) -> None:
"""Show details for a bundled skill."""
_prepare_skills_output(output_format)
skill = get_builtin_skill(skill_name)
markdown = read_skill_markdown(skill)
_, body = split_frontmatter(markdown)
when_to_use = extract_section(body, "When To Use")
quick_start = None
for heading in (
"Triage Order",
"Golden Paths",
"Core Workflow",
"Command Map",
"Common Commands",
"Fast Path",
"Inspect Current Policy",
"Preferred Workflow",
):
quick_start = extract_section(body, heading)
if quick_start:
break
json_shapes = None
if "```json" in body:
start = body.find("```json")
end = body.find("```", start + 7)
if start != -1 and end != -1:
json_shapes = body[start + 7 : end].strip()
payload = {
"skill": skill.slug,
"title": skill.title,
"area": _SKILL_AREAS.get(skill.slug, "General"),
"summary": skill.summary,
"trigger_hint": skill.trigger_hint,
"when_to_use": when_to_use,
"quick_start": quick_start,
"json_shapes": json_shapes,
"content": markdown.strip(),
}
def _render_table() -> None:
click.echo(f"Skill: {skill.slug}")
click.echo(f"Title: {skill.title}")
click.echo(f"Area: {_SKILL_AREAS.get(skill.slug, 'General')}")
click.echo(f"Summary: {skill.summary}")
click.echo(f"Trigger: {skill.trigger_hint}")
click.echo()
if when_to_use:
click.echo("When To Use:")
click.echo(when_to_use)
click.echo()
if quick_start:
click.echo("Quick Start:")
click.echo(quick_start)
click.echo()
for heading in ("Minimal Closed Loops", "Response Pattern", "Guidance"):
section = extract_section(body, heading)
if section:
click.echo(f"{heading}:")
click.echo(section)
click.echo()
if json_shapes:
click.echo("JSON Shapes:")
click.echo(json_shapes)
click.echo()
click.echo("Full Skill:")
click.echo(markdown.strip())
_emit_output(table_renderer=_render_table, data=payload)
@skills_group.command("list")
@output_option("table", "json", "yaml")
@handle_errors
def skills_list(output_format: str | None) -> None:
"""List bundled skills, supported targets, and install status."""
_prepare_skills_output(output_format)
skill_rows = [
{
**asdict(skill),
"area": _SKILL_AREAS.get(skill.slug, "General"),
"source_path": str(get_builtin_skill_source(skill)),
}
for skill in list_builtin_skills()
]
target_rows: list[dict[str, object]] = []
for target_name, cfg in _TARGETS.items():
label = str(cfg["label"])
for scope_name in cfg["scopes"]:
installed_skills = []
for skill in list_builtin_skills():
dest = _target_destination(target_name, scope_name, skill)
status = "installed" if _is_installed(target_name, scope_name, skill) else "not installed"
installed_skills.append(
{
"skill": skill.slug,
"status": status,
"path": str(dest),
}
)
target_rows.append(
{
"target": target_name,
"scope": scope_name,
"label": label,
"layout": _target_layout_summary(target_name, scope_name),
"skills": installed_skills,
}
)
def _render_table() -> None:
click.echo("Bundled skills:\n")
for skill in list_builtin_skills():
area = _SKILL_AREAS.get(skill.slug, "General")
click.echo(f" {skill.slug:<24} [{area}] {skill.summary}")
click.echo(f" {'':<24} Trigger: {skill.trigger_hint}")
click.echo("\nSupported targets:\n")
for target_row in target_rows:
click.echo(
f" {target_row['target']:<10} {target_row['scope']:<8} "
f"{target_row['label']:<18} {target_row['layout']}"
)
for skill_row in cast(list[dict[str, str]], target_row["skills"]):
click.echo(
f" {'':<10} {'':<8} {'':<18} {skill_row['skill']:<24} "
f"{skill_row['status']:<13} ({skill_row['path']})"
)
_emit_output(
table_renderer=_render_table,
data={
"skills": skill_rows,
"targets": target_rows,
},
)
@skills_group.command("uninstall")
@click.argument(
"skill_name",
required=False,
default=DEFAULT_SKILL,
type=click.Choice(_ALL_SKILL_NAMES, case_sensitive=False),
)
@click.option(
"--target",
"-t",
type=click.Choice(_ALL_TARGET_NAMES + ["all"], case_sensitive=False),
default=None,
help="Target AI tool to remove the skill from.",
)
@click.option(
"--scope",
type=click.Choice(_ALL_SCOPE_NAMES, case_sensitive=False),
default=None,
help="Install scope to remove from.",
)
@output_option("table", "json", "yaml")
@handle_errors
def skills_uninstall(
skill_name: str,
target: str | None,
scope: str | None,
output_format: str | None,
) -> None:
"""Remove an installed OpenSandbox skill from one or more AI tools."""
_prepare_skills_output(output_format)
if target is None:
raise click.UsageError(
"Missing required option '--target'.\n\n" + _install_guidance_text()
)
if scope is None:
raise click.UsageError(
"Missing required option '--scope'.\n\n" + _install_guidance_text()
)
skill = get_builtin_skill(skill_name)
targets = _ALL_TARGET_NAMES if target == "all" else [target]
results: list[UninstallResult] = []
for target_name in targets:
label = str(_TARGETS[target_name]["label"])
removed, dest = _uninstall_target(target_name, scope, skill)
results.append(
{
"skill": skill.slug,
"target": target_name,
"target_label": label,
"scope": scope,
"path": str(dest),
"status": "removed" if removed else "not_installed",
"requires_restart": True,
}
)
def _render_table() -> None:
for result in results:
click.echo(
f" {result['status']:<15} "
f"{result['target_label']} [{result['scope']}]: "
f"{result['skill']} -> {result['path']}"
)
_emit_output(
table_renderer=_render_table,
data={
"operations": results,
"requires_restart": True,
},
)
+160
View File
@@ -0,0 +1,160 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""CLI configuration loading and management.
Priority (highest to lowest):
1. CLI flags
2. Environment variables
3. Config file (~/.opensandbox/config.toml)
4. SDK defaults
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import Any
if sys.version_info >= (3, 11):
import tomllib
else:
try:
import tomli as tomllib # type: ignore[no-redef]
except ModuleNotFoundError: # pragma: no cover
tomllib = None # type: ignore[assignment]
DEFAULT_CONFIG_DIR = Path.home() / ".opensandbox"
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml"
DEFAULT_CONFIG_TEMPLATE = """\
# OpenSandbox CLI configuration
# Priority: CLI flags > environment variables > this file > SDK defaults
[connection]
# api_key = "your-api-key"
# domain = "localhost:8080"
# protocol = "http"
# request_timeout = 30
# use_server_proxy = false
[output]
# color = true
[defaults]
# image = "python:3.11"
# timeout = "10m" # or "none" for manual cleanup mode
"""
def load_config_file(config_path: Path | None = None) -> dict[str, Any]:
"""Load and parse the TOML config file.
Returns an empty dict if the file doesn't exist or tomllib is unavailable.
"""
path = config_path or DEFAULT_CONFIG_PATH
if not path.exists():
return {}
if tomllib is None:
return {}
with open(path, "rb") as f:
return tomllib.load(f)
def resolve_config(
*,
cli_api_key: str | None = None,
cli_domain: str | None = None,
cli_protocol: str | None = None,
cli_timeout: int | None = None,
cli_use_server_proxy: bool | None = None,
config_path: Path | None = None,
) -> dict[str, Any]:
"""Merge config from all sources and return a flat dict.
Keys returned:
- api_key, domain, protocol, request_timeout (int seconds), use_server_proxy (bool)
- default_image, default_timeout (str like "10m")
"""
file_cfg = load_config_file(config_path)
conn = file_cfg.get("connection", {})
output_cfg = file_cfg.get("output", {})
defaults = file_cfg.get("defaults", {})
return {
"api_key": cli_api_key
or os.getenv("OPEN_SANDBOX_API_KEY")
or conn.get("api_key"),
"domain": cli_domain
or os.getenv("OPEN_SANDBOX_DOMAIN")
or conn.get("domain"),
"protocol": cli_protocol
or os.getenv("OPEN_SANDBOX_PROTOCOL")
or conn.get("protocol")
or "http",
"request_timeout": cli_timeout
or _int_or_none(os.getenv("OPEN_SANDBOX_REQUEST_TIMEOUT"))
or conn.get("request_timeout")
or 30,
"use_server_proxy": _coalesce(
cli_use_server_proxy,
_bool_or_none(os.getenv("OPEN_SANDBOX_USE_SERVER_PROXY")),
conn.get("use_server_proxy"),
False,
),
"color": output_cfg.get("color", True),
"default_image": defaults.get("image"),
"default_timeout": defaults.get("timeout"),
}
def init_config_file(config_path: Path | None = None, *, force: bool = False) -> Path:
"""Create a default config file. Returns the path written."""
path = config_path or DEFAULT_CONFIG_PATH
if path.exists() and not force:
raise FileExistsError(
f"Config file already exists at {path}. Use --force to overwrite."
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(DEFAULT_CONFIG_TEMPLATE)
return path
def _int_or_none(value: str | None) -> int | None:
if value is None:
return None
try:
return int(value)
except ValueError:
return None
def _bool_or_none(value: str | None) -> bool | None:
if value is None:
return None
normalized = value.strip().lower()
if normalized in ("1", "true", "yes", "on"):
return True
if normalized in ("0", "false", "no", "off"):
return False
return None
def _coalesce(*values: Any) -> Any:
for value in values:
if value is not None:
return value
return None
+142
View File
@@ -0,0 +1,142 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Root Click group with global options."""
from __future__ import annotations
from pathlib import Path
import click
from rich.console import Console
from opensandbox_cli import __version__
from opensandbox_cli.client import ClientContext
from opensandbox_cli.commands.command import command_group
from opensandbox_cli.commands.config_cmd import config_group
from opensandbox_cli.commands.credential_vault import credential_vault_group
from opensandbox_cli.commands.devops import devops_group
from opensandbox_cli.commands.diagnostics import diagnostics_group
from opensandbox_cli.commands.egress import egress_group
from opensandbox_cli.commands.file import file_group
from opensandbox_cli.commands.sandbox import sandbox_group
from opensandbox_cli.commands.skills import skills_group
from opensandbox_cli.config import resolve_config
# ---------------------------------------------------------------------------
# Banner
# ---------------------------------------------------------------------------
BANNER = r"""[bold cyan]
____ _____ _ _
/ __ \ / ____| | | |
| | | |_ __ ___ _ _| (___ __ _ _ __ __| | |__ _____ __
| | | | '_ \ / _ \ '_ \___ \ / _` | '_ \ / _` | '_ \ / _ \ \/ /
| |__| | |_) | __/ | | |___) | (_| | | | | (_| | |_) | (_) > <
\____/| .__/ \___|_| |_|____/ \__,_|_| |_|\__,_|_.__/ \___/_/\_\
| |
|_|[/] [dim]v{version}[/]
"""
class BannerGroup(click.Group):
"""Custom Click group that shows a banner before help text."""
def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
console = Console(stderr=False)
console.print(BANNER.format(version=__version__))
super().format_help(ctx, formatter)
@click.group(cls=BannerGroup, context_settings={"help_option_names": ["-h", "--help"]})
@click.option("--api-key", envvar="OPEN_SANDBOX_API_KEY", default=None, help="API key for authentication.")
@click.option("--domain", envvar="OPEN_SANDBOX_DOMAIN", default=None, help="API server domain (e.g. localhost:8080).")
@click.option("--protocol", type=click.Choice(["http", "https"]), default=None, help="Protocol (http/https).")
@click.option("--request-timeout", type=int, default=None, help="Request timeout in seconds.")
@click.option(
"--use-server-proxy/--no-use-server-proxy",
default=None,
help="Route execd and endpoint traffic through the sandbox server proxy.",
)
@click.option("--config", "config_path", type=click.Path(exists=False, path_type=Path), default=None, help="Config file path.")
@click.option("-v", "--verbose", is_flag=True, default=False, help="Enable verbose/debug output.")
@click.option("--no-color", is_flag=True, default=False, help="Disable colored output.")
@click.version_option(version=__version__, prog_name="opensandbox")
@click.pass_context
def cli(
ctx: click.Context,
api_key: str | None,
domain: str | None,
protocol: str | None,
request_timeout: int | None,
use_server_proxy: bool | None,
config_path: Path | None,
verbose: bool,
no_color: bool,
) -> None:
"""OpenSandbox CLI — manage sandboxes from your terminal."""
if verbose:
import logging
logging.basicConfig(level=logging.DEBUG)
cli_overrides = {
"api_key": api_key,
"domain": domain,
"protocol": protocol,
"request_timeout": request_timeout,
"use_server_proxy": use_server_proxy,
}
if ctx.invoked_subcommand == "config":
resolved = {
"api_key": api_key,
"domain": domain,
"protocol": protocol or "http",
"request_timeout": request_timeout or 30,
"use_server_proxy": use_server_proxy if use_server_proxy is not None else False,
"color": True,
"default_image": None,
"default_timeout": None,
}
else:
resolved = resolve_config(
cli_api_key=api_key,
cli_domain=domain,
cli_protocol=protocol,
cli_timeout=request_timeout,
cli_use_server_proxy=use_server_proxy,
config_path=config_path,
)
resolved["color"] = not no_color and resolved.get("color", True)
effective_config_path = config_path or Path.home() / ".opensandbox" / "config.toml"
ctx.obj = ClientContext(
resolved_config=resolved,
config_path=effective_config_path,
cli_overrides=cli_overrides,
)
ctx.call_on_close(lambda: ctx.obj.close())
# Register sub-command groups
cli.add_command(sandbox_group)
cli.add_command(command_group)
cli.add_command(file_group)
cli.add_command(egress_group)
cli.add_command(credential_vault_group)
cli.add_command(config_group)
cli.add_command(diagnostics_group)
cli.add_command(devops_group)
cli.add_command(skills_group)
+363
View File
@@ -0,0 +1,363 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Output formatting: table (rich), JSON, YAML."""
from __future__ import annotations
import json
import sys
from collections.abc import Generator, Sequence
from contextlib import contextmanager
from typing import Any
import click
try:
import yaml
except ImportError: # pragma: no cover
yaml = None # type: ignore[assignment]
from pydantic import BaseModel
from rich import box
from rich.console import Console
from rich.panel import Panel
from rich.status import Status
from rich.table import Table
from rich.text import Text
# ---------------------------------------------------------------------------
# Status badge styling (sandbox state → color + icon)
# ---------------------------------------------------------------------------
_STATUS_STYLES: dict[str, tuple[str, str]] = {
# state → (rich style, icon)
"running": ("bold green", ""),
"ready": ("bold green", ""),
"healthy": ("bold green", ""),
"pending": ("bold yellow", ""),
"creating": ("bold yellow", ""),
"starting": ("bold yellow", ""),
"paused": ("bold blue", ""),
"stopped": ("dim", ""),
"terminated": ("dim", ""),
"killed": ("dim", ""),
"error": ("bold red", ""),
"failed": ("bold red", ""),
"unhealthy": ("bold red", ""),
"created": ("bold cyan", ""),
}
# Columns that contain status-like values
_STATUS_COLUMNS = {"status", "state", "healthy"}
# Columns that should be rendered in a dimmer style (long IDs, timestamps)
_DIM_COLUMNS = {"created_at", "expires_at", "modified_at", "updated_at"}
# Columns that are primary identifiers
_ID_COLUMNS = {"id", "sandbox_id", "execution_id", "context_id"}
def _style_value(col: str, value: str) -> Text:
"""Apply contextual styling to a cell value."""
lower = value.lower()
if col in _STATUS_COLUMNS:
style, icon = _STATUS_STYLES.get(lower, ("", ""))
if style:
return Text(f"{icon} {value}", style=style)
if col in _DIM_COLUMNS:
return Text(value, style="dim")
if col in _ID_COLUMNS:
return Text(value, style="bold cyan")
return Text(value)
class OutputFormatter:
"""Renders data in table / json / yaml format."""
def __init__(self, fmt: str = "table", *, color: bool = True) -> None:
self.fmt = fmt
self.color = color
self.console = Console(
stderr=False, no_color=not color, force_terminal=None
)
self._err_console = Console(
stderr=True, no_color=not color, force_terminal=None
)
# ------------------------------------------------------------------
# Status messages with icons
# ------------------------------------------------------------------
def success(self, msg: str) -> None:
"""Print a success message with ✅ icon."""
if self.fmt == "json":
self._print_json({"status": "ok", "message": msg})
return
if self.fmt == "yaml":
self._print_yaml({"status": "ok", "message": msg})
return
if self.color:
self.console.print(f" [bold green]✅ {msg}[/]")
else:
click.echo(f"OK: {msg}")
def info(self, msg: str) -> None:
"""Print an info message with ️ icon."""
if self.fmt == "json":
self._print_json({"status": "info", "message": msg})
return
if self.fmt == "yaml":
self._print_yaml({"status": "info", "message": msg})
return
if self.color:
self.console.print(f" [bold blue]{msg}[/]")
else:
click.echo(f"INFO: {msg}")
def warning(self, msg: str) -> None:
"""Print a warning message with ⚠️ icon."""
if self.fmt == "json":
self._print_json({"status": "warning", "message": msg})
return
if self.fmt == "yaml":
self._print_yaml({"status": "warning", "message": msg})
return
if self.color:
self._err_console.print(f" [bold yellow]⚠️ {msg}[/]")
else:
click.echo(f"WARN: {msg}", err=True)
def error(self, msg: str) -> None:
"""Print an error message with ❌ icon."""
if self.fmt == "json":
self._print_json({"status": "error", "message": msg})
return
if self.fmt == "yaml":
self._print_yaml({"status": "error", "message": msg})
return
if self.color:
self._err_console.print(f" [bold red]❌ {msg}[/]")
else:
click.echo(f"ERROR: {msg}", err=True)
def error_panel(self, msg: str, title: str = "Error") -> None:
"""Print an error with a bold header and message."""
if self.color:
self._err_console.print()
self._err_console.print(f" [bold red]{title}[/]")
self._err_console.print(f" [dim]{'' * (len(title) + 2)}[/]")
for line in msg.splitlines():
self._err_console.print(f" {line}")
self._err_console.print()
else:
click.echo(f"ERROR [{title}]: {msg}", err=True)
# ------------------------------------------------------------------
# Spinner for long-running operations
# ------------------------------------------------------------------
@contextmanager
def spinner(self, msg: str) -> Generator[Status, None, None]:
"""Context manager that shows a spinner while work is in progress."""
if self.color and self.fmt == "table":
with self._err_console.status(f"[bold cyan]⏳ {msg}[/]", spinner="dots") as status:
yield status
else:
# No spinner in non-color or non-table mode
yield None # type: ignore[arg-type]
# ------------------------------------------------------------------
# Panel output
# ------------------------------------------------------------------
def panel(self, content: str, *, title: str | None = None, style: str = "cyan") -> None:
"""Print content inside a styled panel."""
if self.color:
self.console.print(Panel(
content,
title=title,
title_align="left",
border_style=style,
box=box.ROUNDED,
padding=(0, 1),
))
else:
if title:
click.echo(f"--- {title} ---")
click.echo(content)
def success_panel(self, data: dict[str, Any], *, title: str = "Success") -> None:
"""Print a success result with a header and indented key-value pairs."""
if self.fmt != "table":
if self.fmt == "json":
self._print_json(data)
elif self.fmt == "yaml":
self._print_yaml(data)
return
if self.color:
self.console.print()
self.console.print(f" [bold green]✓ {title}[/]")
self.console.print(f" [dim]{'' * (len(title) + 2)}[/]")
for k, v in data.items():
self.console.print(f" [bold]{k}:[/] [cyan]{v}[/]")
self.console.print()
else:
click.echo(f"--- {title} ---")
for k, v in data.items():
click.echo(f" {k}: {v}")
# ------------------------------------------------------------------
# Public helpers
# ------------------------------------------------------------------
def print_model(self, model: BaseModel, title: str | None = None) -> None:
"""Print a single Pydantic model as key-value panel or JSON/YAML."""
data = _model_to_dict(model)
if self.fmt == "json":
self._print_json(data)
elif self.fmt == "yaml":
self._print_yaml(data)
else:
self._print_kv_table(data, title=title)
def print_models(
self,
models: Sequence[BaseModel],
columns: list[str],
*,
title: str | None = None,
) -> None:
"""Print a list of Pydantic models as a table or JSON/YAML."""
rows = [_model_to_dict(m) for m in models]
if self.fmt == "json":
self._print_json(rows)
elif self.fmt == "yaml":
self._print_yaml(rows)
else:
self._print_table(rows, columns, title=title)
def print_rows(
self,
rows: list[dict[str, Any]],
columns: list[str],
*,
title: str | None = None,
) -> None:
"""Print pre-processed rows (list of dicts) as a table or JSON/YAML."""
if self.fmt == "json":
self._print_json(rows)
elif self.fmt == "yaml":
self._print_yaml(rows)
else:
self._print_table(rows, columns, title=title)
def print_dict(self, data: dict[str, Any], title: str | None = None) -> None:
"""Print a flat dict."""
if self.fmt == "json":
self._print_json(data)
elif self.fmt == "yaml":
self._print_yaml(data)
else:
self._print_kv_table(data, title=title)
def print_text(self, text: str) -> None:
"""Print raw text (ignores format)."""
click.echo(text)
# ------------------------------------------------------------------
# Internal renderers
# ------------------------------------------------------------------
def _print_json(self, data: Any) -> None:
if self.color:
self.console.print_json(json.dumps(data, default=str))
else:
click.echo(json.dumps(data, indent=2, default=str))
def _print_yaml(self, data: Any) -> None:
if yaml is None:
click.secho(
"PyYAML is not installed. Use --output json instead.", fg="red", err=True
)
sys.exit(1)
click.echo(yaml.dump(data, default_flow_style=False, allow_unicode=True).rstrip())
def _print_kv_table(self, data: dict[str, Any], *, title: str | None = None) -> None:
table = Table(
title=title,
show_header=True,
header_style="bold magenta",
title_style="bold cyan",
box=box.ROUNDED,
border_style="bright_black",
padding=(0, 1),
show_lines=True,
)
table.add_column("Key", style="bold cyan", no_wrap=True)
table.add_column("Value")
for k, v in data.items():
val_text = _style_value(k, str(v)) if v is not None else Text("-", style="dim")
table.add_row(str(k), val_text)
self.console.print(table)
def _print_table(
self,
rows: list[dict[str, Any]],
columns: list[str],
*,
title: str | None = None,
) -> None:
table = Table(
title=title,
show_header=True,
header_style="bold magenta",
title_style="bold cyan",
box=box.ROUNDED,
border_style="bright_black",
padding=(0, 1),
row_styles=["", "dim"],
)
for col in columns:
style = ""
if col in _ID_COLUMNS:
style = "bold cyan"
elif col in _DIM_COLUMNS:
style = "dim"
table.add_column(col.upper(), style=style, no_wrap=(col in _ID_COLUMNS))
for row in rows:
cells: list[Text | str] = []
for col in columns:
val = str(row.get(col, "-"))
if col in _STATUS_COLUMNS:
cells.append(_style_value(col, val))
else:
cells.append(val)
table.add_row(*cells)
self.console.print(table)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _model_to_dict(model: BaseModel) -> dict[str, Any]:
return model.model_dump(mode="json")
+1
View File
@@ -0,0 +1 @@
+199
View File
@@ -0,0 +1,199 @@
"""Built-in OpenSandbox skill metadata and rendering helpers."""
from __future__ import annotations
import importlib.resources
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class SkillSpec:
"""A bundled skill shipped with the CLI."""
slug: str
package_file: str
title: str
summary: str
trigger_hint: str
marker_id: str
BUILTIN_SKILLS: dict[str, SkillSpec] = {
"sandbox-troubleshooting": SkillSpec(
slug="sandbox-troubleshooting",
package_file="opensandbox-sandbox-troubleshooting.md",
title="OpenSandbox Sandbox Troubleshooting",
summary=(
"Triage failed or unhealthy sandboxes with state, health, stable "
"diagnostic logs/events, and concrete remediation steps."
),
trigger_hint=(
"Use when the user reports sandbox startup failures, crashes, OOM, "
"image pull problems, pending sandboxes, or unreachable services."
),
marker_id="opensandbox-sandbox-troubleshooting",
),
"sandbox-lifecycle": SkillSpec(
slug="sandbox-lifecycle",
package_file="opensandbox-sandbox-lifecycle.md",
title="OpenSandbox Sandbox Lifecycle",
summary=(
"Create, inspect, renew, pause, resume, and terminate sandboxes "
"with the right defaults and follow-up checks."
),
trigger_hint=(
"Use when the user wants to create or manage a sandbox and needs "
"the exact OpenSandbox CLI/API flow."
),
marker_id="opensandbox-sandbox-lifecycle",
),
"command-execution": SkillSpec(
slug="command-execution",
package_file="opensandbox-command-execution.md",
title="OpenSandbox Command Execution",
summary=(
"Run foreground and background commands, inspect status/logs, and "
"use persistent sessions inside a sandbox."
),
trigger_hint=(
"Use when the user wants to execute commands in a sandbox, collect "
"logs, interrupt work, or reuse a persistent shell session."
),
marker_id="opensandbox-command-execution",
),
"file-operations": SkillSpec(
slug="file-operations",
package_file="opensandbox-file-operations.md",
title="OpenSandbox File Operations",
summary=(
"Read, write, upload, download, search, replace, and manage files "
"inside a sandbox without hand-wavy shell advice."
),
trigger_hint=(
"Use when the user needs file or directory manipulation inside an "
"OpenSandbox sandbox."
),
marker_id="opensandbox-file-operations",
),
"network-egress": SkillSpec(
slug="network-egress",
package_file="opensandbox-network-egress.md",
title="OpenSandbox Network Egress",
summary=(
"Inspect and patch sandbox runtime egress rules when the user "
"needs to allow or deny outbound domains."
),
trigger_hint=(
"Use when the user needs to view or modify outbound network access "
"for a sandbox, or debug domain allow and deny behavior."
),
marker_id="opensandbox-network-egress",
),
"credential-vault": SkillSpec(
slug="credential-vault",
package_file="opensandbox-credential-vault.md",
title="OpenSandbox Credential Vault",
summary=(
"Create, inspect, patch, and delete sandbox-local Credential Vault "
"state without exposing plaintext secrets in shell flags."
),
trigger_hint=(
"Use when the user needs outbound credential injection for tools "
"inside a sandbox, or wants to manage Credential Vault credentials "
"and bindings at runtime."
),
marker_id="opensandbox-credential-vault",
),
}
DEFAULT_SKILL = "sandbox-troubleshooting"
def list_builtin_skills() -> list[SkillSpec]:
"""Return bundled skills in stable declaration order."""
return list(BUILTIN_SKILLS.values())
def get_builtin_skill(slug: str) -> SkillSpec:
"""Return a bundled skill definition."""
return BUILTIN_SKILLS[slug]
def get_builtin_skill_source(skill: SkillSpec) -> Path:
"""Locate the bundled skill file shipped with the CLI package."""
pkg = importlib.resources.files("opensandbox_cli") / "skills" / skill.package_file
if hasattr(pkg, "__fspath__"):
return Path(str(pkg))
with importlib.resources.as_file(pkg) as resolved:
return Path(resolved)
def read_skill_markdown(skill: SkillSpec) -> str:
"""Load bundled skill markdown."""
return get_builtin_skill_source(skill).read_text(encoding="utf-8")
def split_frontmatter(markdown: str) -> tuple[str | None, str]:
"""Split markdown into YAML frontmatter and body."""
if not markdown.startswith("---\n"):
return None, markdown
closing = markdown.find("\n---\n", 4)
if closing == -1:
return None, markdown
frontmatter = markdown[4:closing]
body = markdown[closing + 5 :]
return frontmatter, body
def extract_section(body: str, heading: str) -> str | None:
"""Extract a markdown section by exact heading text."""
lines = body.splitlines()
capture = False
capture_level = 0
captured: list[str] = []
for line in lines:
if line.startswith("## ") or line.startswith("### "):
if capture:
if capture_level == 2 and line.startswith("## "):
break
if capture_level == 3 and (line.startswith("## ") or line.startswith("### ")):
break
if line == f"## {heading}":
capture = True
capture_level = 2
continue
if line == f"### {heading}":
capture = True
capture_level = 3
continue
if capture:
captured.append(line)
if not captured:
return None
return "\n".join(captured).strip() or None
def render_skill_for_target(
skill: SkillSpec,
markdown: str,
*,
preserve_frontmatter: bool,
) -> str:
"""Render skill content for the target tool."""
frontmatter, body = split_frontmatter(markdown)
body = body.strip()
if preserve_frontmatter or not frontmatter:
return markdown.strip() + "\n"
return (
f"# {skill.title}\n\n"
f"{skill.summary}\n\n"
f"{body}\n"
)
@@ -0,0 +1,215 @@
---
name: command-execution
description: Use OpenSandbox command execution commands to run foreground or background processes, inspect tracked execution status and logs, interrupt work, and manage persistent shell sessions inside a sandbox. Trigger when users want to execute commands in a sandbox and need exact OpenSandbox CLI flows.
---
# OpenSandbox Command Execution
Run commands with `osb command`. Use foreground streaming by default, and add `--background` when you need tracked execution.
## When To Use
- the user wants to run a one-off command in a sandbox
- the user needs a tracked background command with later status/log inspection
- the user wants to stop a running command
- the user wants a persistent shell session that keeps working directory or environment state across runs
## Config Gate
Run this first:
```bash
osb config show -o json
```
If `domain` is missing, stop and set it before command execution:
```bash
osb config set connection.domain <host:port> -o json
osb config show -o json
```
If auth is required and `api_key` is missing, stop and set it before command execution:
```bash
osb config set connection.api_key <api-key> -o json
osb config show -o json
```
## Separator Rule
Use `--` before the sandbox command payload. This is required when the payload contains flags like `-l`, `-c`, or `-m`.
```bash
osb command run <sandbox-id> -o raw -- sh -lc 'echo ready'
osb command run <sandbox-id> -o raw -- python -m http.server
osb command session run <sandbox-id> <session-id> -o raw -- sh -lc 'pwd'
```
## Execution Modes
Treat these as three distinct execution paths:
- `osb command run` without `--background`
Use for foreground one-shot commands when the result should stream directly to the terminal
- `osb command run --background`
Use when the user needs an execution ID, later status checks, or log retrieval
- `osb command session ...`
Use when shell state must persist across commands, such as exported variables or a working directory
## Golden Paths
Foreground one-shot command:
```bash
osb command run <sandbox-id> -o raw -- python -c "print(1 + 1)"
```
Tracked background command:
```bash
osb command run <sandbox-id> --background -o json -- sh -c "sleep 10; echo done"
osb command status <sandbox-id> <execution-id> -o json
osb command logs <sandbox-id> <execution-id> -o json
```
Persistent session:
```bash
osb command session create <sandbox-id> --workdir /workspace -o json
osb command session run <sandbox-id> <session-id> -o raw -- pwd
osb command session run <sandbox-id> <session-id> -o raw -- export FOO=bar
osb command session run <sandbox-id> <session-id> -o raw -- sh -c 'echo $FOO'
osb command session delete <sandbox-id> <session-id> -o json
```
## Foreground Commands
For simple one-off execution, use:
```bash
osb command run <sandbox-id> -o raw -- <command>
osb command run <sandbox-id> --workdir /workspace -o raw -- <command>
osb command run <sandbox-id> --timeout 30s -o raw -- <command>
```
Use foreground mode when the user wants immediate output and does not need a tracked execution ID.
## Background Commands
Use background mode when the user will need follow-up inspection:
```bash
osb command run <sandbox-id> --background -o json -- <command>
osb command run <sandbox-id> --background --workdir /workspace -o json -- <command>
osb command run <sandbox-id> --background --timeout 5m -o json -- <command>
```
Then inspect the tracked execution:
```bash
osb command status <sandbox-id> <execution-id> -o json
osb command logs <sandbox-id> <execution-id> -o json
osb command logs <sandbox-id> <execution-id> --cursor 0 -o json
```
Use `status` for lifecycle state and exit information. Use `logs` for tracked background output. Do not suggest `command logs` for foreground commands that already streamed to the terminal.
## Persistent Sessions
Use sessions when commands must share shell state:
```bash
osb command session create <sandbox-id> --workdir /workspace -o json
osb command session run <sandbox-id> <session-id> -o raw -- pwd
osb command session run <sandbox-id> <session-id> -o raw -- export FOO=bar
osb command session run <sandbox-id> <session-id> -o raw -- sh -c 'echo $FOO'
osb command session run <sandbox-id> <session-id> --workdir /var -o raw -- pwd
osb command session delete <sandbox-id> <session-id> -o json
```
Rules:
- `session create --workdir` sets the initial working directory for the session
- `session run --workdir` overrides the working directory for that single run only
- exported variables and shell state persist across `session run` calls in the same session
- delete the session when the user is done; do not leave idle sessions around
## Interrupting Work
Interrupt only tracked executions:
```bash
osb command interrupt <sandbox-id> <execution-id> -o json
```
Only suggest interruption when the user explicitly wants to stop work or the process is clearly stuck.
## Failure Semantics
- foreground `osb command run` streams output directly and requires `-o raw`
- background `osb command run --background` returns tracked execution metadata and supports structured output
- `session run` also exits non-zero on execution error
- tracked background commands should be checked with `status` and `logs`
- if the command failure is caused by an unhealthy sandbox rather than the command itself, switch to `sandbox-troubleshooting`
## Response Pattern
Structure the answer as:
1. exact command to run
2. which execution mode it uses
3. the next inspection or cleanup command if the workflow continues
Keep command examples concrete and ready to paste.
## Minimal Closed Loops
Foreground command with timeout:
```bash
osb command run <sandbox-id> --timeout 30s -o raw -- python -c "print(1 + 1)"
```
Tracked background execution:
```bash
osb command run <sandbox-id> --background -o json -- sh -c "sleep 10; echo done"
osb command status <sandbox-id> <execution-id> -o json
osb command logs <sandbox-id> <execution-id> -o json
```
Background execution with interrupt:
```bash
osb command run <sandbox-id> --background -o json -- sh -c "sleep 300"
osb command interrupt <sandbox-id> <execution-id> -o json
osb command status <sandbox-id> <execution-id> -o json
```
Persistent session with shared shell state:
```bash
osb command session create <sandbox-id> --workdir /workspace -o json
osb command session run <sandbox-id> <session-id> -o raw -- export FOO=bar
osb command session run <sandbox-id> <session-id> -o raw -- sh -c 'echo $FOO'
osb command session delete <sandbox-id> <session-id> -o json
```
Per-run working directory override inside a session:
```bash
osb command session create <sandbox-id> --workdir /tmp -o json
osb command session run <sandbox-id> <session-id> -o raw -- pwd
osb command session run <sandbox-id> <session-id> --workdir /var -o raw -- pwd
osb command session delete <sandbox-id> <session-id> -o json
```
## Best Practices
- use `osb command run -o raw -- ...` for quick foreground commands
- use `--background` when the user will need execution tracking
- use sessions only when state persistence is actually needed
- use `--workdir` explicitly when directory context matters
- use `--timeout` when the command should not run indefinitely
- prefer `status` before guessing whether a background command is still running or already failed
@@ -0,0 +1,136 @@
---
name: credential-vault
description: Use OpenSandbox Credential Vault commands to manage sandbox-local outbound credential injection state. Trigger when users need to write credentials and bindings, inspect sanitized vault metadata, or update runtime credential injection rules for an existing sandbox.
---
# OpenSandbox Credential Vault
Manage outbound credential injection with `osb credential-vault`. Treat vault
changes as a controlled runtime workflow: confirm the sandbox was created with
Credential Proxy, write secrets only through payload files or stdin, inspect
sanitized state, then verify actual outbound behavior from inside the sandbox.
## When To Use
- the user needs real credentials injected into outbound HTTP or HTTPS requests
- the user wants to create, inspect, patch, or delete Credential Vault state
- the user needs to add, replace, or remove runtime credentials or bindings
- the user wants to keep plaintext credentials out of sandbox env vars, files,
shell history, and command arguments
## Prerequisites
Credential Vault requires an egress sidecar with transparent MITM enabled. Create
the sandbox with both a network policy and Credential Proxy:
```bash
osb sandbox create --image python:3.12 --network-policy-file network-policy.json --credential-proxy -o json
```
If `credential-vault create`, `patch`, or `delete` returns a precondition error,
the sandbox was likely not created with Credential Proxy, the egress API auth
token is missing, mitmproxy is not ready, or insecure upstream TLS mode is set.
## Payload Rules
Use JSON or YAML payload files, or `--file -` for stdin. Do not put real secret
values in command-line flags.
Create payload shape:
```yaml
credentials:
- name: api-token
source:
value: secret-value
bindings:
- name: api
match:
schemes: [https]
hosts: [api.example.com]
paths: [/v1/*]
auth:
type: apiKey
name: x-api-key
credential: api-token
```
Patch payload shape:
```yaml
expectedRevision: 1
credentials:
add:
- name: runtime-token
source:
value: runtime-secret
bindings:
add:
- name: runtime-api
match:
schemes: [https]
hosts: [api.example.com]
auth:
type: bearer
credential: runtime-token
```
## Golden Path
```bash
osb credential-vault create <sandbox-id> --file vault.yaml -o json
osb credential-vault get <sandbox-id> -o json
osb credential-vault credential list <sandbox-id> -o json
osb credential-vault binding list <sandbox-id> -o json
osb command run <sandbox-id> -o raw -- curl -I https://api.example.com
```
## Runtime Mutation
Patch with optimistic concurrency when the current revision matters:
```bash
osb credential-vault get <sandbox-id> -o json
osb credential-vault patch <sandbox-id> --file mutation.yaml -o json
osb credential-vault get <sandbox-id> -o json
```
Delete specific credentials or bindings by naming them in the patch payload:
```yaml
expectedRevision: 2
bindings:
delete: [runtime-api]
credentials:
delete: [runtime-token]
```
## Inspect Metadata
Vault read APIs return sanitized metadata only. Plaintext credential values are
write-only and should not appear in command output.
```bash
osb credential-vault credential get <sandbox-id> api-token -o json
osb credential-vault binding get <sandbox-id> api -o json
```
## Cleanup
Delete all sandbox-local vault state when credential injection is no longer
needed:
```bash
osb credential-vault delete <sandbox-id> -o json
```
## Response Pattern
When helping a user:
1. Confirm the active OpenSandbox config with `osb config show -o json`.
2. Confirm the sandbox was created with `--credential-proxy` and network policy.
3. Keep secret material in payload files or stdin, never in CLI flags.
4. Use `get` or `list` to inspect sanitized state.
5. Verify behavior with `osb command run ... -o raw -- curl ...` when injection
behavior matters.
@@ -0,0 +1,244 @@
---
name: file-operations
description: Use OpenSandbox file commands to read, write, upload, download, search, replace, move, delete, and inspect files or directories inside a sandbox. Trigger when users want exact sandbox file manipulation commands instead of generic shell guidance.
---
# OpenSandbox File Operations
Manipulate sandbox files with `osb file` commands. Choose the operation mode first, then use the matching verification step. Do not mix sandbox-internal edits with host-to-sandbox transfer commands casually.
## When To Use
- the user wants to read or write a file inside a sandbox
- the user needs to upload a local file into a sandbox or download a sandbox file back to the host
- the user wants to search, replace, move, chmod, or inspect paths
- the user needs directory creation or cleanup
## Config Gate
Run this first:
```bash
osb config show -o json
```
If `domain` is missing, stop and set it before file commands:
```bash
osb config set connection.domain <host:port> -o json
osb config show -o json
```
If auth is required and `api_key` is missing, stop and set it before file commands:
```bash
osb config set connection.api_key <api-key> -o json
osb config show -o json
```
## Operation Modes
Treat these as distinct categories:
- sandbox-only content operations
`cat`, `write`, `replace`, `mv`, `mkdir`, `rm`, `rmdir`, `info`, `chmod`
- host-to-sandbox transfer
`upload`
- sandbox-to-host transfer
`download`
- discovery before modification
`search`, then `info`
If the path is uncertain, search first. If the file boundary crosses between host and sandbox, use `upload` or `download` instead of `write` or `cat`.
## Golden Paths
Write and verify inside the sandbox:
```bash
osb file write <sandbox-id> /workspace/app.txt -c "hello" -o json
osb file cat <sandbox-id> /workspace/app.txt -o raw
```
Upload from host and verify in the sandbox:
```bash
osb file upload <sandbox-id> ./local.txt /workspace/local.txt -o json
osb file cat <sandbox-id> /workspace/local.txt -o raw
```
Search before editing:
```bash
osb file search <sandbox-id> /workspace --pattern "*.py" -o json
osb file info <sandbox-id> /workspace/main.py -o json
```
## Sandbox-Only File Edits
Read and write:
```bash
osb file cat <sandbox-id> /path/to/file -o raw
osb file write <sandbox-id> /path/to/file -c "hello" -o json
```
Use `write` with `-c/--content` when the new content is known directly. If the content should come from stdin, omit `-c` and pipe or paste the content into the command.
Edit existing content:
```bash
osb file replace <sandbox-id> /path/to/file --old old --new new -o json
osb file mv <sandbox-id> /old/path /new/path -o json
```
Prefer `replace` for small text substitutions and `mv` for rename/path changes. Do not rewrite a full file when a targeted replace is enough.
Create directories:
```bash
osb file mkdir <sandbox-id> /workspace/output -o json
osb file mkdir <sandbox-id> /workspace/a /workspace/b --mode 755 -o json
```
## Host <-> Sandbox Transfer
Host to sandbox:
```bash
osb file upload <sandbox-id> ./local.txt /remote/path/local.txt -o json
```
Sandbox to host:
```bash
osb file download <sandbox-id> /remote/path/result.json ./result.json -o json
```
Rules:
- use `upload` when the source file is on the host
- use `download` when the destination should be written to the host filesystem
- use `write` and `cat` only when the operation stays entirely inside the sandbox
## Metadata and Permissions
Inspect metadata:
```bash
osb file info <sandbox-id> /path/to/file -o json
osb file info <sandbox-id> /path/one /path/two -o json
```
Search by pattern:
```bash
osb file search <sandbox-id> /workspace --pattern "*.py" -o json
```
Set permissions:
```bash
osb file chmod <sandbox-id> /path/to/script --mode 755 -o json
osb file chmod <sandbox-id> /path/to/file --mode 644 --owner root --group root -o json
```
Use `info` after `chmod` when the user needs to confirm mode, ownership, or timestamps changed as expected.
## Destructive Operations
Delete files or directories only after verifying the target path:
```bash
osb file info <sandbox-id> /workspace/tmp.txt -o json
osb file rm <sandbox-id> /workspace/tmp.txt -o json
```
```bash
osb file search <sandbox-id> /workspace --pattern "old-*" -o json
osb file rmdir <sandbox-id> /workspace/old-dir -o json
```
Rules:
- prefer `info` when the exact path is known
- prefer `search` when the path is uncertain
- do not suggest `rm` or `rmdir` until the target has been verified
- after `mv`, `rm`, or `rmdir`, verify the new state with `info` or `search`
## Failure Semantics
- `upload` and `download` have host filesystem side effects; treat them as cross-boundary operations
- `download` writes to the local path immediately, so be explicit about the destination
- permission or ownership failures are usually path/runtime permission issues, not a reason to switch away from `osb file`
- if multiple file commands fail unexpectedly, check sandbox health before assuming a file-command bug
## Response Pattern
Structure the answer as:
1. exact `osb file` command
2. which operation mode it uses
3. the next verification command if the workflow continues
Keep command examples concrete and ready to paste.
## Minimal Closed Loops
Write and verify:
```bash
osb file write <sandbox-id> /workspace/app.txt -c "hello" -o json
osb file cat <sandbox-id> /workspace/app.txt -o raw
```
Upload and verify:
```bash
osb file upload <sandbox-id> ./local.txt /workspace/local.txt -o json
osb file cat <sandbox-id> /workspace/local.txt -o raw
```
Replace and verify:
```bash
osb file replace <sandbox-id> /workspace/app.txt --old hello --new world -o json
osb file cat <sandbox-id> /workspace/app.txt -o raw
```
Change permissions and inspect:
```bash
osb file chmod <sandbox-id> /workspace/script.sh --mode 755 -o json
osb file info <sandbox-id> /workspace/script.sh -o json
```
Create a directory and inspect it:
```bash
osb file mkdir <sandbox-id> /workspace/output -o json
osb file info <sandbox-id> /workspace/output -o json
```
Move a file and verify the new path:
```bash
osb file mv <sandbox-id> /workspace/app.txt /workspace/archive/app.txt -o json
osb file info <sandbox-id> /workspace/archive/app.txt -o json
```
Delete and verify removal:
```bash
osb file info <sandbox-id> /workspace/tmp.txt -o json
osb file rm <sandbox-id> /workspace/tmp.txt -o json
osb file search <sandbox-id> /workspace --pattern "tmp.txt" -o json
```
## Best Practices
- prefer `search` before modification when the path is not certain
- prefer `info` before destructive actions
- prefer `replace` over full rewrites for small text changes
- prefer `upload` and `download` only for host boundary crossings
- prefer explicit verification after `mv`, `chmod`, `rm`, and `rmdir`
@@ -0,0 +1,179 @@
---
name: network-egress
description: Use OpenSandbox egress commands to inspect and patch runtime outbound network policy for a sandbox. Trigger when users want to allow or deny domains, confirm current egress rules, or debug outbound network access problems caused by policy.
---
# OpenSandbox Network Egress
Manage outbound network access with `osb egress`. Treat runtime egress patching as a policy workflow, not just a one-line command. Always inspect current state, patch only what is needed, then verify both policy text and actual network behavior.
## When To Use
- the user wants to see current egress rules for a sandbox
- the user wants to allow or deny one or more outbound domains for an existing sandbox
- the user reports outbound access issues and runtime policy may be the cause
- the user wants an exact OpenSandbox command for runtime network policy changes
## Configuration Resolution
Before suggesting egress commands, resolve the active OpenSandbox connection configuration:
```bash
osb config show -o json
```
Check the resolved values for:
- `domain`
- `api_key`
- `protocol`
`osb config show` redacts the API key. Use it to confirm the effective target, not to recover the credential itself.
Do not assume the user configures OpenSandbox only through environment variables. Work from the resolved configuration that the CLI will actually use.
Hard stop:
```bash
osb config show -o json
```
If `domain` is missing, stop and set it before policy changes:
```bash
osb config set connection.domain <host:port> -o json
osb config show -o json
```
If auth is required and `api_key` is missing, stop and set it before policy changes:
```bash
osb config set connection.api_key <api-key> -o json
osb config show -o json
```
## Policy Model
Runtime egress policy consists of:
- `defaultAction`
The fallback action when no rule matches
- `egress`
An ordered list of allow or deny rules
Important semantics:
- if `defaultAction` is omitted, the policy model defaults to `deny`
- runtime patching uses merge semantics; it is not a full policy replacement workflow
- targets should be domain-based, such as `pypi.org` or `*.example.com`
- do not assume IP or CIDR targets are supported in this workflow
Use `osb egress patch` for already-created sandboxes. Use `osb sandbox create --network-policy-file ...` when the user wants to define policy at sandbox creation time.
## Golden Paths
Inspect and patch:
```bash
osb egress get <sandbox-id> -o json
osb egress patch <sandbox-id> --rule allow=pypi.org -o json
osb egress get <sandbox-id> -o json
```
Inspect, patch, and verify actual behavior:
```bash
osb egress get <sandbox-id> -o json
osb egress patch <sandbox-id> --rule allow=www.github.com --rule deny=pypi.org -o json
osb egress get <sandbox-id> -o json
osb command run <sandbox-id> -o raw -- curl -I https://www.github.com
osb command run <sandbox-id> -o raw -- curl -I https://pypi.org
```
## Inspect Current Policy
Start by reading the current runtime policy:
```bash
osb egress get <sandbox-id> -o json
```
Use this before patching whenever the existing state matters.
## Patch Runtime Rules
Patch only the rules the user actually asked for:
```bash
osb egress patch <sandbox-id> --rule allow=pypi.org -o json
osb egress patch <sandbox-id> --rule deny=internal.example.com -o json
osb egress patch <sandbox-id> --rule allow=*.example.com -o json
osb egress patch <sandbox-id> --rule allow=www.github.com --rule deny=pypi.org -o json
```
Rules:
- keep patches narrow and auditable
- express changes as explicit `allow` or `deny` entries
- do not present patching as a full replacement of the policy object
## Behavior Verification
Do not stop at policy text if the user is debugging connectivity. Verify runtime behavior with actual commands:
```bash
osb command run <sandbox-id> -o raw -- curl -I https://pypi.org
osb command run <sandbox-id> -o raw -- curl -I https://www.github.com
```
Use:
- `egress get` to confirm the current rule set
- `osb command run ... -o raw -- curl ...` to verify whether outbound access is actually allowed or denied
## Runtime Notes
- use lifecycle create-time policy files when the sandbox has not been created yet
- use `osb egress patch` only for an already-running or already-created sandbox
- if network access is still wrong after a correct patch, continue with `sandbox-troubleshooting` instead of assuming the patch command failed silently
## Response Pattern
Structure the answer as:
1. exact `osb egress` command
2. what policy change it applies
3. the verification command to run next
Keep command examples concrete and ready to paste.
## Minimal Closed Loops
Allow one domain and verify:
```bash
osb egress get <sandbox-id> -o json
osb egress patch <sandbox-id> --rule allow=pypi.org -o json
osb egress get <sandbox-id> -o json
osb command run <sandbox-id> -o raw -- curl -I https://pypi.org
```
Flip behavior between two domains:
```bash
osb egress get <sandbox-id> -o json
osb egress patch <sandbox-id> --rule allow=www.github.com --rule deny=pypi.org -o json
osb egress get <sandbox-id> -o json
osb command run <sandbox-id> -o raw -- curl -I https://www.github.com
osb command run <sandbox-id> -o raw -- curl -I https://pypi.org
```
## Best Practices
- resolve the active connection configuration before patching policy on a specific server
- prefer `osb config show` over checking isolated environment variables
- inspect before patching
- patch only the minimum required domains
- verify actual behavior, not just rule text
- prefer explicit domain targets over broad wildcards unless the user truly needs them
- prefer create-time policy files for initial sandbox provisioning and runtime patching for later adjustment
@@ -0,0 +1,307 @@
---
name: sandbox-lifecycle
description: Use OpenSandbox CLI lifecycle commands to create, inspect, verify, renew, pause, resume, expose, and terminate sandboxes. Trigger when users want help provisioning a sandbox, choosing create flags, checking runtime state, retrieving endpoints, or safely cleaning up sandboxes.
---
# OpenSandbox Sandbox Lifecycle
Use OpenSandbox lifecycle commands directly instead of giving generic container advice. Prefer a verified lifecycle flow over isolated commands.
## When To Use
- the user wants to create a sandbox for a task or workflow
- the user needs to inspect sandbox state or health
- the user wants to expose a service port through an OpenSandbox endpoint
- the user wants to renew, pause, resume, or terminate a sandbox
- the user is unsure which create flags or file-based inputs to use
## Configuration Resolution
Before giving lifecycle commands, resolve the active OpenSandbox connection configuration:
```bash
osb config show -o json
```
Check the resolved values for:
- `domain`
- `api_key`
- `protocol`
- `use_server_proxy` when endpoint routing matters
`osb config show` redacts the API key. Use it to confirm which server and protocol the CLI will target, not to recover credentials.
Do not focus only on environment variables. `osb config show` already reflects the effective configuration after applying CLI flags, environment variables, config file values, and defaults.
Hard stop:
```bash
osb config show -o json
```
If `domain` is missing, stop and set it before lifecycle commands:
```bash
osb config set connection.domain <host:port> -o json
osb config show -o json
```
If auth is required and `api_key` is missing, stop and set it before lifecycle commands:
```bash
osb config set connection.api_key <api-key> -o json
osb config show -o json
```
If the effective target is clear, then continue with lifecycle commands.
## Preflight Checks
Run these checks before lifecycle commands:
```bash
osb --version
osb config show -o json
```
If `osb` is missing, try a project-local entrypoint:
```bash
.venv/bin/osb --version
.venv/bin/osb config show -o json
```
Use the results to classify the situation:
- CLI available
- config resolves
- target looks plausible for the current environment
## Golden Path
Use this as the default lifecycle flow unless the user asks for a narrower action:
```bash
osb config show -o json
osb sandbox create --image python:3.12 --timeout 30m -o json
osb sandbox get <sandbox-id> -o json
osb sandbox health <sandbox-id> -o json
```
If the sandbox is intended to serve traffic on a known port, continue with:
```bash
osb sandbox endpoint <sandbox-id> --port 8080 -o json
```
This sequence is safer than stopping at `sandbox get`, because `get` confirms object state while `health` confirms the sandbox is actually reachable through the execd health path.
## Create Options
Start with the narrowest create command that matches the request:
```bash
osb sandbox create --image python:3.12 -o json
osb sandbox create --image node:20 --timeout 30m -o json
osb sandbox create --image my-registry.example.com/team/app:latest --image-auth-username alice --image-auth-password <token> -o json
osb sandbox create --image python:3.12 --timeout none -o json
osb sandbox create --image python:3.12 --entrypoint python --entrypoint -m --entrypoint http.server -o json
osb sandbox create --image python:3.12 --extension storage.id=abc123 -o json
osb sandbox create --image python:3.12 --ready-timeout 90s -o json
osb sandbox create --image python:3.12 --network-policy-file network-policy.json -o json
osb sandbox create --image python:3.12 --network-policy-file network-policy.json --credential-proxy -o json
osb sandbox create --image python:3.12 --volumes-file volumes.json -o json
```
Use these options deliberately:
- `--image`: required unless the CLI already has `defaults.image` configured
- `--image-auth-username` and `--image-auth-password`: use together when the image is in a private registry
- `--timeout`: recommended for most temporary workloads so sandboxes do not linger indefinitely
- `--timeout none`: disable automatic expiration and switch the sandbox to manual cleanup mode
- omit `--timeout`: use `defaults.timeout` when configured; otherwise the request falls back to the SDK/server default TTL
- `--entrypoint`: repeat the flag once per argv item; do not use JSON or shell-wrapped command strings
- `--extension`: repeat for opaque extension key-value pairs that should be passed through as-is
- `--ready-timeout`: increase this when the image or workload needs more startup time
- `--skip-health-check`: use only when the user explicitly wants object creation without waiting for readiness; do not use it to mask startup problems
- `--credential-proxy`: enable Credential Vault transparent proxy support; requires `--network-policy-file`
If the user does not specify an image, recommend one that matches the runtime they need instead of guessing silently.
## JSON Shapes
When recommending `--network-policy-file` or `--volumes-file`, always show the JSON shape instead of assuming the user knows it.
Example `network-policy.json`:
```json
{
"defaultAction": "deny",
"egress": [
{
"action": "allow",
"target": "pypi.org"
},
{
"action": "allow",
"target": "files.pythonhosted.org"
}
]
}
```
Example `volumes-host.json`:
```json
[
{
"name": "workspace-data",
"host": {
"path": "/tmp/opensandbox-data"
},
"mountPath": "/workspace/data",
"readOnly": false
}
]
```
Example `volumes-pvc.json`:
```json
[
{
"name": "shared-models",
"pvc": {
"claimName": "shared-models-pvc"
},
"mountPath": "/workspace/models",
"readOnly": true
}
]
```
Prefer `pvc` when the environment supports it and the user needs a more portable storage definition. Use `host` when the user explicitly needs a host-path bind mount and the server has been configured to allow that path.
## Verification
Use verification commands in this order:
```bash
osb sandbox get <sandbox-id> -o json
osb sandbox health <sandbox-id> -o json
osb sandbox metrics <sandbox-id> -o json
osb sandbox metrics <sandbox-id> --watch -o raw
```
Use:
- `sandbox get` to inspect the current lifecycle state and metadata
- `sandbox health` to confirm the sandbox is usable
- `sandbox metrics` for a point-in-time resource snapshot
- `sandbox metrics --watch` when the user wants live CPU and memory updates while diagnosing load or pressure
If the user needs a public or routed port, verify it explicitly:
```bash
osb sandbox endpoint <sandbox-id> --port 8080 -o json
```
## Lifecycle Actions
Use these commands for ongoing lifecycle management:
```bash
osb sandbox list -o json
osb sandbox list --state running --state paused -o json
osb sandbox renew <sandbox-id> --timeout 30m -o json
osb sandbox pause <sandbox-id> -o json
osb sandbox resume <sandbox-id> -o json
osb sandbox kill <sandbox-id> -o json
```
Rules:
- use `sandbox list` for discovery or filtering, not single-sandbox verification
- `sandbox list --state` accepts known lifecycle states case-insensitively
- use `renew` before long-running work instead of waiting for expiry
- use `pause` only when the workload can tolerate suspension
- use `kill` when cleanup is the real goal; do not leave orphaned sandboxes behind
## Runtime Notes
- `renew` resets the expiration to approximately `now + timeout`; treat it as a fresh TTL, not a simple additive extension to the old timestamp
- `create --timeout none` means no automatic expiration; cleanup becomes an explicit `kill` responsibility
- `create` without `--timeout` does not mean manual cleanup; it uses `defaults.timeout` first and otherwise leaves TTL selection to the SDK/server default
- `pause` and `resume` may depend on the underlying runtime; if the runtime does not support them, avoid promising they will work
- host-path volumes depend on server-side allowed host path configuration
- if creation fails or the sandbox never becomes healthy, switch to `sandbox-troubleshooting` instead of adding more create flags blindly
## Response Pattern
Structure the answer as:
1. exact command to run
2. what state change or verification result to expect
3. the next lifecycle command if the workflow continues
Keep command examples concrete and ready to paste.
## Minimal Closed Loops
Create and verify readiness:
```bash
osb sandbox create --image python:3.12 --timeout 30m -o json
osb sandbox get <sandbox-id> -o json
osb sandbox health <sandbox-id> -o json
```
Create a service sandbox and retrieve its endpoint:
```bash
osb sandbox create --image python:3.12 --timeout 30m -o json
osb sandbox health <sandbox-id> -o json
osb sandbox endpoint <sandbox-id> --port 8080 -o json
```
Create with network policy:
```bash
osb sandbox create --image python:3.12 --network-policy-file network-policy.json -o json
osb sandbox get <sandbox-id> -o json
osb sandbox health <sandbox-id> -o json
```
Renew before long work:
```bash
osb sandbox renew <sandbox-id> --timeout 30m -o json
osb sandbox get <sandbox-id> -o json
```
Pause and confirm state:
```bash
osb sandbox pause <sandbox-id> -o json
osb sandbox get <sandbox-id> -o json
```
Resume and verify health:
```bash
osb sandbox resume <sandbox-id> -o json
osb sandbox health <sandbox-id> -o json
```
## Best Practices
- resolve the active connection configuration before assuming which server the command will hit
- prefer `osb config show` over checking individual environment variables in isolation
- confirm whether the user wants to keep, override, or persist connection settings before changing them
- Prefer explicit `--image` and `--timeout` when demonstrating commands
- Prefer `health` over assuming readiness from `create` output alone
- Prefer `endpoint` over telling the user to guess host/port mappings
- Prefer `pvc` over `host` when portability matters
- Prefer troubleshooting over `--skip-health-check` when startup is failing
@@ -0,0 +1,202 @@
---
name: sandbox-troubleshooting
description: Use OpenSandbox CLI state, health, and stable diagnostics logs/events to investigate failed, unhealthy, or unreachable sandboxes. Trigger when users report startup failures, crashes, OOM, image pull problems, pending sandboxes, network issues, or an unresponsive sandbox and want root cause plus next actions.
---
# OpenSandbox Sandbox Troubleshooting
Investigate the reported sandbox before proposing a fix. Prefer evidence from OpenSandbox state, health checks, and stable diagnostics streams over speculation.
## Inputs To Collect
Capture these from the user request or surrounding context before running commands:
- sandbox ID or an unambiguous short ID
- whether `osb` or `opensandbox` CLI is available locally
- any reported symptom: pending forever, crash, OOM, unreachable service, bad image, failed exec, etc.
If the sandbox ID is missing, ask for it first.
## Configuration Resolution
Before troubleshooting a sandbox, resolve the active OpenSandbox connection configuration:
```bash
osb config show -o json
```
Check the resolved values for:
- `domain`
- `api_key`
- `protocol`
- `use_server_proxy` when endpoint routing or proxy behavior may matter
`osb config show` redacts the API key. Use it to confirm the effective target server and protocol before troubleshooting.
If `domain` is missing, stop and set it first:
```bash
osb config set connection.domain <host:port> -o json
osb config show -o json
```
If auth is required and `api_key` is missing, stop and set it first:
```bash
osb config set connection.api_key <api-key> -o json
osb config show -o json
```
Use raw HTTP only after domain, protocol, and API key expectations are explicit.
## Operating Rules
- start with the highest-signal commands first: sandbox state, sandbox health, then stable diagnostics events/logs
- use CLI commands when `osb` is available because they are shorter and usually already authenticated
- use HTTP only when the CLI is unavailable or the user is clearly working from raw API access
- distinguish observed facts from inference and quote the field, event, or log line that supports the diagnosis
- separate sandbox/runtime failures from workload/application failures before suggesting a fix
- do not paper over readiness problems with `--skip-health-check`
- end with a likely root cause and 1-3 concrete remediation steps
## Triage Order
Use this order by default:
```bash
osb sandbox get <sandbox-id> -o json
osb sandbox health <sandbox-id> -o json
osb diagnostics events <sandbox-id> --scope lifecycle -o raw
osb diagnostics events <sandbox-id> --scope runtime -o raw
osb diagnostics logs <sandbox-id> --scope container -o raw
```
Then drill down only where the stable diagnostics point:
```bash
osb diagnostics logs <sandbox-id> --scope lifecycle -o raw
osb diagnostics events <sandbox-id> --scope all -o raw
osb diagnostics logs <sandbox-id> --scope all -o raw
```
## Diagnostics Streams
Important properties of the diagnostics commands:
- `diagnostics events` and `diagnostics logs` are stable API-backed commands
- `--scope` is required for stable diagnostics; requests without scope use deprecated plain-text DevOps behavior
- if the server returns `DIAGNOSTICS_NOT_IMPLEMENTED`, state that stable diagnostics are unavailable on this server and stop diagnostics collection
- use known supported scopes first: `events:lifecycle`, `events:runtime`, `logs:lifecycle`, and `logs:container`
- `--scope all` is useful when the server supports aggregate diagnostics; if it is empty, retry concrete supported scopes
- other scopes such as `network` or `process` are server-defined and may be empty on some deployments
- `-o raw` prints inline diagnostic text directly, or a content URL when the server returns URL delivery
- `-o json` / `-o yaml` prints the CLI descriptor including `delivery`, `content_url`, `expires_at`, `truncated`, and `warnings`
- quote concrete lines from diagnostics output instead of summarizing vaguely
Use:
- `osb diagnostics events <sandbox-id> --scope lifecycle -o raw` for sandbox actions such as `CREATE`, `RENEW`, `DELETE`, `PAUSE`, `RESUME`, and `FORK`
- `osb diagnostics events <sandbox-id> --scope runtime -o raw` for scheduler and container events such as `Scheduled`, `Pulling`, `Pulled`, `Created`, `Started`, and `ContainerDied`
- `osb diagnostics logs <sandbox-id> --scope lifecycle -o raw` for manager server logs related to create, renew, delete, callbacks, request IDs, and server-side failures
- `osb diagnostics logs <sandbox-id> --scope container -o raw` for sandbox main-process stdout, including application errors, missing binaries, bad entrypoints, startup hangs, and health-check failures
## Evidence Semantics
- `sandbox get` shows control-plane state; it does not prove the workload is healthy
- `sandbox health` shows readiness or endpoint health; it can fail even when the sandbox is running
- lifecycle diagnostics explain OpenSandbox manager behavior; container logs explain the user workload
- runtime events are platform facts and usually outrank application logs for scheduling, image pull, restart, and kill reasons
- empty diagnostics do not prove there is no issue; the scope may be unsupported, expired, or outside retention
- `truncated: true` means the evidence is incomplete; lower confidence and mention the truncation
- always read `warnings`; they may explain missing, partial, unsupported, or expired diagnostic content
## URL Delivery
- with `delivery: inline`, use `content` as the diagnostic text
- with `delivery: url`, `-o raw` prints the diagnostic content URL; fetch it only if you need the diagnostic body
- `content_url` in structured CLI output is a diagnostic artifact URL, not a sandbox service endpoint
- check `expires_at`; container log URLs may expire quickly, so request diagnostics again if the URL is stale
- do not forward diagnostic URLs or lifecycle logs to unrelated people because they may contain sensitive troubleshooting data
## Symptom To Command Mapping
Use the first command that best matches the reported symptom:
| Symptom | First command | What to confirm next |
| --- | --- | --- |
| pending forever or stuck creating | `osb diagnostics events <sandbox-id> --scope runtime -o raw` | image pull errors, scheduling failures, admission errors, then lifecycle logs |
| image pull failure | `osb diagnostics events <sandbox-id> --scope runtime -o raw` | image name, tag, registry auth |
| crash loop or repeated restarts | `osb diagnostics logs <sandbox-id> --scope container -o raw` | `osb diagnostics events <sandbox-id> --scope runtime -o raw` for restarts or kill signals |
| suspected OOM or exit code issue | `osb diagnostics events <sandbox-id> --scope runtime -o raw` | kill signals, restart events, resource pressure messages |
| endpoint unreachable or connection refused | `osb sandbox health <sandbox-id> -o json` | `osb sandbox endpoint <sandbox-id> --port <port> -o json` and then `osb diagnostics logs <sandbox-id> --scope container -o raw` |
| outbound network access failure | `osb sandbox health <sandbox-id> -o json` | check service behavior, then switch to `network-egress` if the issue is egress policy related |
## Diagnosis Playbooks
### Image Pull Failure
- first evidence: `events` shows `ImagePullBackOff`, `ErrImagePull`, or auth failures
- confirming evidence: sandbox stays `Pending` or never reaches healthy state
- likely cause: bad image reference or missing registry credentials
- next actions: verify image URI and tag, fix registry auth, recreate the sandbox
### OOM Kill
- first evidence: runtime events mention container killed due to out-of-memory or resource pressure
- confirming evidence: sandbox becomes unhealthy, restarts, or exits after memory-intensive work
- likely cause: memory limit too low for the workload
- next actions: increase memory, rerun the workload, compare peak workload memory with the configured limit
### Crash Loop Or Bad Entrypoint
- first evidence: `logs` show startup exceptions, missing binaries, or permission errors
- confirming evidence: runtime events show repeated restarts, failed starts, or container exit reasons
- likely cause: bad entrypoint, missing executable, or application crash on boot
- next actions: fix the command or image contents, correct file permissions, redeploy or recreate
### Endpoint Or Service Unreachable
- first evidence: sandbox is `Running` but client requests fail or connection is refused
- confirming evidence: `sandbox endpoint <id> --port <port>` is missing, wrong, or points to a service that is not listening
- likely cause: wrong exposed port, service not bound, or server endpoint host misconfiguration
- next actions: verify the port, inspect service logs, and if the endpoint host is unreachable from the client environment check the server endpoint configuration
## Minimal Closed Loops
CLI-first troubleshooting:
```bash
osb sandbox get <sandbox-id> -o json
osb sandbox health <sandbox-id> -o json
osb diagnostics events <sandbox-id> --scope lifecycle -o raw
osb diagnostics events <sandbox-id> --scope runtime -o raw
osb diagnostics logs <sandbox-id> --scope container -o raw
```
Crash-focused investigation:
```bash
osb diagnostics logs <sandbox-id> --scope container -o raw
osb diagnostics events <sandbox-id> --scope runtime -o raw
```
Endpoint troubleshooting:
```bash
osb sandbox get <sandbox-id> -o json
osb sandbox health <sandbox-id> -o json
osb sandbox endpoint <sandbox-id> --port <port> -o json
osb diagnostics logs <sandbox-id> --scope container -o raw
```
## Response Format
Structure the answer in this order:
1. current state: what the sandbox is doing now
2. evidence: the command output that matters
3. root cause: the most likely diagnosis, stated as confidence not certainty when needed
4. next actions: specific fixes or follow-up checks
Keep the conclusion compact.
+212
View File
@@ -0,0 +1,212 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Shared CLI utilities: duration parsing, output selection, error handling, key-value parsing."""
from __future__ import annotations
import functools
import re
import sys
from datetime import timedelta
import click
from opensandbox_cli.client import ClientContext
# ---------------------------------------------------------------------------
# Duration parsing (e.g. "10m", "1h30m", "90s", "2h")
# ---------------------------------------------------------------------------
_DURATION_RE = re.compile(
r"^(?:(?P<hours>\d+)h)?(?:(?P<minutes>\d+)m)?(?:(?P<seconds>\d+)s)?$"
)
def parse_duration(value: str) -> timedelta:
"""Parse a human-friendly duration string into a ``timedelta``.
Supported formats: ``10m``, ``1h30m``, ``90s``, ``2h``, ``1h30m45s``.
A plain integer is treated as seconds.
"""
value = value.strip()
if not value:
raise click.BadParameter("Duration cannot be empty")
# Plain integer → seconds
if value.isdigit():
return timedelta(seconds=int(value))
m = _DURATION_RE.match(value)
if not m or not m.group(0):
raise click.BadParameter(
f"Invalid duration '{value}'. Use format like 10m, 1h30m, 90s."
)
hours = int(m.group("hours") or 0)
minutes = int(m.group("minutes") or 0)
seconds = int(m.group("seconds") or 0)
return timedelta(hours=hours, minutes=minutes, seconds=seconds)
def parse_nullable_duration(value: str) -> timedelta | None:
"""Parse a duration string or the literal ``none``.
``none`` means no automatic timeout / manual cleanup mode.
"""
normalized = value.strip().lower()
if normalized == "none":
return None
return parse_duration(value)
class DurationType(click.ParamType):
"""Click parameter type for duration strings."""
name = "duration"
def convert(
self, value: str, param: click.Parameter | None, ctx: click.Context | None
) -> timedelta:
if isinstance(value, timedelta):
return value
try:
return parse_duration(value)
except click.BadParameter:
self.fail(
f"Invalid duration '{value}'. Use format like 10m, 1h30m, 90s.",
param,
ctx,
)
DURATION = DurationType()
# ---------------------------------------------------------------------------
# Key=Value parsing (e.g. --env FOO=bar)
# ---------------------------------------------------------------------------
class KeyValueType(click.ParamType):
"""Click parameter type that parses ``KEY=VALUE`` strings into a tuple."""
name = "KEY=VALUE"
def convert(
self, value: str, param: click.Parameter | None, ctx: click.Context | None
) -> tuple[str, str]:
if isinstance(value, tuple):
return value
if "=" not in value:
self.fail(f"Expected KEY=VALUE format, got '{value}'", param, ctx)
key, _, val = value.partition("=")
return (key, val)
KEY_VALUE = KeyValueType()
# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------
def output_option(
*choices: str,
default: str | None = None,
help_text: str | None = None,
):
"""Attach a command-scoped output option."""
option_help = help_text or f"Output format: {', '.join(choices)}."
return click.option(
"-o",
"--output",
"output_format",
type=click.Choice(list(choices), case_sensitive=False),
default=None if default is None else default,
show_default=default is not None,
help=option_help,
)
def select_output_format(
obj: ClientContext,
requested: str | None,
*,
allowed: tuple[str, ...],
fallback: str,
) -> str:
"""Resolve a command-scoped output format from explicit input, config, and fallback."""
if requested:
if requested not in allowed:
allowed_list = ", ".join(allowed)
raise click.ClickException(
f"This command does not support `-o {requested}`. Allowed values: {allowed_list}."
)
return requested
return fallback
def prepare_output(
obj: ClientContext,
requested: str | None,
*,
allowed: tuple[str, ...],
fallback: str,
):
"""Resolve and attach the formatter for the current command."""
fmt = select_output_format(obj, requested, allowed=allowed, fallback=fallback)
return obj.make_output(fmt)
# ---------------------------------------------------------------------------
# Error handling decorator
# ---------------------------------------------------------------------------
def handle_errors(fn): # type: ignore[no-untyped-def]
"""Decorator that catches SDK / HTTP exceptions and prints a friendly message."""
@functools.wraps(fn)
def wrapper(*args, **kwargs): # type: ignore[no-untyped-def]
try:
return fn(*args, **kwargs)
except click.exceptions.Exit:
raise
except click.ClickException:
raise
except Exception as exc:
# Import here to avoid circular imports at module level
from opensandbox.exceptions import SandboxException
# Try to get the OutputFormatter from the Click context
ctx = click.get_current_context(silent=True)
obj = getattr(ctx, "obj", None) if ctx else None
output = getattr(obj, "output", None) if obj else None
if output and hasattr(output, "error_panel"):
if isinstance(exc, SandboxException):
output.error_panel(str(exc), title="Sandbox Error")
else:
output.error_panel(
f"{str(exc)}\n\n[dim]Type: {type(exc).__qualname__}[/]",
title=type(exc).__name__,
)
else:
click.secho(f"Error: {exc}", fg="red", err=True)
sys.exit(1)
return wrapper
View File
+65
View File
@@ -0,0 +1,65 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Shared test fixtures."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from click.testing import CliRunner
from opensandbox_cli.output import OutputFormatter
@pytest.fixture()
def runner() -> CliRunner:
return CliRunner()
@pytest.fixture()
def mock_manager() -> MagicMock:
return MagicMock()
@pytest.fixture()
def mock_sandbox() -> MagicMock:
return MagicMock()
@pytest.fixture()
def mock_client_context(mock_manager: MagicMock, mock_sandbox: MagicMock) -> MagicMock:
"""A mock ClientContext that avoids real SDK/HTTP calls."""
ctx = MagicMock()
ctx.resolved_config = {
"api_key": "test-key",
"domain": "localhost:8080",
"protocol": "http",
"request_timeout": 30,
"color": False,
"default_image": None,
"default_timeout": None,
}
ctx.output = OutputFormatter("json", color=False)
def _make_output(fmt: str) -> OutputFormatter:
formatter = OutputFormatter(fmt, color=False)
ctx.output = formatter
return formatter
ctx.make_output.side_effect = _make_output
ctx.get_manager.return_value = mock_manager
ctx.connect_sandbox.return_value = mock_sandbox
ctx.connection_config = MagicMock()
ctx.close = MagicMock()
return ctx
+247
View File
@@ -0,0 +1,247 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests that all CLI commands register correctly and --help exits cleanly."""
from __future__ import annotations
import pytest
from click.testing import CliRunner
from opensandbox_cli.main import cli
@pytest.fixture()
def runner() -> CliRunner:
return CliRunner()
# ---------------------------------------------------------------------------
# Root
# ---------------------------------------------------------------------------
class TestRootCLI:
def test_help(self, runner: CliRunner) -> None:
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "OpenSandbox CLI" in result.output
assert "--request-timeout" in result.output
assert "--timeout" not in result.output
def test_version(self, runner: CliRunner) -> None:
result = runner.invoke(cli, ["--version"])
assert result.exit_code == 0
assert "opensandbox" in result.output
def test_root_lists_commands(self, runner: CliRunner) -> None:
result = runner.invoke(cli, ["--help"])
for cmd in (
"sandbox",
"command",
"file",
"egress",
"credential-vault",
"config",
"diagnostics",
"devops",
"skills",
):
assert cmd in result.output
# ---------------------------------------------------------------------------
# Sandbox sub-commands
# ---------------------------------------------------------------------------
class TestSandboxHelp:
def test_sandbox_help(self, runner: CliRunner) -> None:
result = runner.invoke(cli, ["sandbox", "--help"])
assert result.exit_code == 0
for subcmd in ("create", "list", "get", "kill", "pause", "resume", "renew", "endpoint", "health", "metrics"):
assert subcmd in result.output
@pytest.mark.parametrize(
"subcmd",
["create", "list", "get", "kill", "pause", "resume", "renew", "endpoint", "health", "metrics"],
)
def test_sandbox_subcommand_help(self, runner: CliRunner, subcmd: str) -> None:
result = runner.invoke(cli, ["sandbox", subcmd, "--help"])
assert result.exit_code == 0
assert subcmd in result.output.lower() or "usage" in result.output.lower()
# ---------------------------------------------------------------------------
# Command sub-commands
# ---------------------------------------------------------------------------
class TestCommandHelp:
def test_command_help(self, runner: CliRunner) -> None:
result = runner.invoke(cli, ["command", "--help"])
assert result.exit_code == 0
for subcmd in ("run", "status", "logs", "interrupt", "session"):
assert subcmd in result.output
@pytest.mark.parametrize("subcmd", ["run", "status", "logs", "interrupt"])
def test_command_subcommand_help(self, runner: CliRunner, subcmd: str) -> None:
result = runner.invoke(cli, ["command", subcmd, "--help"])
assert result.exit_code == 0
def test_command_session_help(self, runner: CliRunner) -> None:
result = runner.invoke(cli, ["command", "session", "--help"])
assert result.exit_code == 0
for subcmd in ("create", "run", "delete"):
assert subcmd in result.output
# ---------------------------------------------------------------------------
# File sub-commands
# ---------------------------------------------------------------------------
class TestFileHelp:
def test_file_help(self, runner: CliRunner) -> None:
result = runner.invoke(cli, ["file", "--help"])
assert result.exit_code == 0
for subcmd in ("cat", "write", "upload", "download", "rm", "mv", "mkdir", "rmdir", "search", "info", "chmod", "replace"):
assert subcmd in result.output
@pytest.mark.parametrize(
"subcmd",
["cat", "write", "upload", "download", "rm", "mv", "mkdir", "rmdir", "search", "info", "chmod", "replace"],
)
def test_file_subcommand_help(self, runner: CliRunner, subcmd: str) -> None:
result = runner.invoke(cli, ["file", subcmd, "--help"])
assert result.exit_code == 0
# ---------------------------------------------------------------------------
# Egress sub-commands
# ---------------------------------------------------------------------------
class TestEgressHelp:
def test_egress_help(self, runner: CliRunner) -> None:
result = runner.invoke(cli, ["egress", "--help"])
assert result.exit_code == 0
for subcmd in ("get", "patch"):
assert subcmd in result.output
# ---------------------------------------------------------------------------
# Credential Vault sub-commands
# ---------------------------------------------------------------------------
class TestCredentialVaultHelp:
def test_credential_vault_help(self, runner: CliRunner) -> None:
result = runner.invoke(cli, ["credential-vault", "--help"])
assert result.exit_code == 0
for subcmd in ("create", "get", "patch", "delete", "credential", "binding"):
assert subcmd in result.output
@pytest.mark.parametrize("subcmd", ["create", "get", "patch", "delete"])
def test_credential_vault_subcommand_help(self, runner: CliRunner, subcmd: str) -> None:
result = runner.invoke(cli, ["credential-vault", subcmd, "--help"])
assert result.exit_code == 0
def test_credential_vault_credential_help(self, runner: CliRunner) -> None:
result = runner.invoke(cli, ["credential-vault", "credential", "--help"])
assert result.exit_code == 0
for subcmd in ("list", "get"):
assert subcmd in result.output
def test_credential_vault_binding_help(self, runner: CliRunner) -> None:
result = runner.invoke(cli, ["credential-vault", "binding", "--help"])
assert result.exit_code == 0
for subcmd in ("list", "get"):
assert subcmd in result.output
# ---------------------------------------------------------------------------
# Config sub-commands
# ---------------------------------------------------------------------------
class TestConfigHelp:
def test_config_help(self, runner: CliRunner) -> None:
result = runner.invoke(cli, ["config", "--help"])
assert result.exit_code == 0
for subcmd in ("init", "show", "set"):
assert subcmd in result.output
@pytest.mark.parametrize("subcmd", ["init", "show", "set"])
def test_config_subcommand_help(self, runner: CliRunner, subcmd: str) -> None:
result = runner.invoke(cli, ["config", subcmd, "--help"])
assert result.exit_code == 0
# ---------------------------------------------------------------------------
# DevOps sub-commands
# ---------------------------------------------------------------------------
class TestDevopsHelp:
def test_devops_help(self, runner: CliRunner) -> None:
result = runner.invoke(cli, ["devops", "--help"])
assert result.exit_code == 0
for subcmd in ("logs", "inspect", "events", "summary"):
assert subcmd in result.output
# ---------------------------------------------------------------------------
# Diagnostics sub-commands
# ---------------------------------------------------------------------------
class TestDiagnosticsHelp:
def test_diagnostics_help(self, runner: CliRunner) -> None:
result = runner.invoke(cli, ["diagnostics", "--help"])
assert result.exit_code == 0
for subcmd in ("logs", "events"):
assert subcmd in result.output
@pytest.mark.parametrize("subcmd", ["logs", "events"])
def test_diagnostics_subcommand_help(self, runner: CliRunner, subcmd: str) -> None:
result = runner.invoke(cli, ["diagnostics", subcmd, "--help"])
assert result.exit_code == 0
assert "--scope" in result.output
assert "content URL" in result.output
def test_diagnostics_logs_help_describes_common_scopes(self, runner: CliRunner) -> None:
result = runner.invoke(cli, ["diagnostics", "logs", "--help"])
assert result.exit_code == 0
assert "lifecycle" in result.output
assert "container" in result.output
def test_diagnostics_events_help_describes_common_scopes(self, runner: CliRunner) -> None:
result = runner.invoke(cli, ["diagnostics", "events", "--help"])
assert result.exit_code == 0
assert "lifecycle" in result.output
assert "runtime" in result.output
# ---------------------------------------------------------------------------
# Skills sub-commands
# ---------------------------------------------------------------------------
class TestSkillsHelp:
def test_skills_help(self, runner: CliRunner) -> None:
result = runner.invoke(cli, ["skills", "--help"])
assert result.exit_code == 0
for subcmd in ("install", "show", "list", "uninstall"):
assert subcmd in result.output
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for opensandbox_cli.config — config loading and priority merging."""
from __future__ import annotations
from pathlib import Path
import pytest
from opensandbox_cli.config import (
DEFAULT_CONFIG_TEMPLATE,
init_config_file,
load_config_file,
resolve_config,
)
# ---------------------------------------------------------------------------
# load_config_file
# ---------------------------------------------------------------------------
class TestLoadConfigFile:
def test_returns_empty_when_file_missing(self, tmp_path: Path) -> None:
result = load_config_file(tmp_path / "nonexistent.toml")
assert result == {}
def test_parses_toml_file(self, tmp_path: Path) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text(
'[connection]\napi_key = "abc"\ndomain = "example.com"\n'
)
result = load_config_file(cfg)
assert result["connection"]["api_key"] == "abc"
assert result["connection"]["domain"] == "example.com"
def test_parses_all_sections(self, tmp_path: Path) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text(
'[connection]\napi_key = "k"\n\n'
'[output]\ncolor = false\n\n'
'[defaults]\nimage = "alpine"\ntimeout = "5m"\n'
)
result = load_config_file(cfg)
assert result["output"]["color"] is False
assert result["defaults"]["image"] == "alpine"
assert result["defaults"]["timeout"] == "5m"
# ---------------------------------------------------------------------------
# resolve_config — priority: CLI > env > file > defaults
# ---------------------------------------------------------------------------
class TestResolveConfig:
def test_defaults_when_nothing_configured(self, tmp_path: Path) -> None:
cfg_path = tmp_path / "empty.toml"
cfg_path.write_text("")
result = resolve_config(config_path=cfg_path)
assert result["api_key"] is None
assert result["domain"] is None
assert result["protocol"] == "http"
assert result["request_timeout"] == 30
assert result["use_server_proxy"] is False
assert result["color"] is True
def test_file_values_override_defaults(self, tmp_path: Path) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text(
'[connection]\napi_key = "file-key"\ndomain = "file.host"\n'
'protocol = "https"\nrequest_timeout = 60\nuse_server_proxy = true\n\n'
'[output]\ncolor = false\n\n'
'[defaults]\nimage = "node:20"\ntimeout = "15m"\n'
)
result = resolve_config(config_path=cfg)
assert result["api_key"] == "file-key"
assert result["domain"] == "file.host"
assert result["protocol"] == "https"
assert result["request_timeout"] == 60
assert result["use_server_proxy"] is True
assert result["color"] is False
assert result["default_image"] == "node:20"
assert result["default_timeout"] == "15m"
def test_env_overrides_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text('[connection]\napi_key = "file-key"\ndomain = "file.host"\n')
monkeypatch.setenv("OPEN_SANDBOX_API_KEY", "env-key")
monkeypatch.setenv("OPEN_SANDBOX_DOMAIN", "env.host")
monkeypatch.setenv("OPEN_SANDBOX_PROTOCOL", "https")
monkeypatch.setenv("OPEN_SANDBOX_REQUEST_TIMEOUT", "120")
monkeypatch.setenv("OPEN_SANDBOX_USE_SERVER_PROXY", "true")
result = resolve_config(config_path=cfg)
assert result["api_key"] == "env-key"
assert result["domain"] == "env.host"
assert result["protocol"] == "https"
assert result["request_timeout"] == 120
assert result["use_server_proxy"] is True
def test_cli_overrides_everything(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text('[connection]\napi_key = "file-key"\n')
monkeypatch.setenv("OPEN_SANDBOX_API_KEY", "env-key")
result = resolve_config(
cli_api_key="cli-key",
cli_domain="cli.host",
cli_protocol="https",
cli_timeout=999,
cli_use_server_proxy=True,
config_path=cfg,
)
assert result["api_key"] == "cli-key"
assert result["domain"] == "cli.host"
assert result["protocol"] == "https"
assert result["request_timeout"] == 999
assert result["use_server_proxy"] is True
def test_invalid_timeout_env_falls_through(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
cfg = tmp_path / "empty.toml"
cfg.write_text("")
monkeypatch.setenv("OPEN_SANDBOX_REQUEST_TIMEOUT", "not-a-number")
result = resolve_config(config_path=cfg)
# Falls through to default 30
assert result["request_timeout"] == 30
def test_invalid_bool_env_falls_through(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
cfg = tmp_path / "empty.toml"
cfg.write_text("")
monkeypatch.setenv("OPEN_SANDBOX_USE_SERVER_PROXY", "not-a-bool")
result = resolve_config(config_path=cfg)
assert result["use_server_proxy"] is False
# ---------------------------------------------------------------------------
# init_config_file
# ---------------------------------------------------------------------------
class TestInitConfigFile:
def test_creates_default_config(self, tmp_path: Path) -> None:
cfg_path = tmp_path / ".opensandbox" / "config.toml"
result = init_config_file(cfg_path)
assert result == cfg_path
assert cfg_path.exists()
content = cfg_path.read_text()
assert "[connection]" in content
assert "[output]" in content
assert "[defaults]" in content
def test_refuses_overwrite_without_force(self, tmp_path: Path) -> None:
cfg_path = tmp_path / "config.toml"
cfg_path.write_text("existing")
with pytest.raises(FileExistsError, match="already exists"):
init_config_file(cfg_path)
def test_force_overwrites(self, tmp_path: Path) -> None:
cfg_path = tmp_path / "config.toml"
cfg_path.write_text("old content")
init_config_file(cfg_path, force=True)
assert cfg_path.read_text() == DEFAULT_CONFIG_TEMPLATE
def test_creates_parent_directories(self, tmp_path: Path) -> None:
cfg_path = tmp_path / "a" / "b" / "c" / "config.toml"
init_config_file(cfg_path)
assert cfg_path.exists()
+136
View File
@@ -0,0 +1,136 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for opensandbox_cli.output — table, JSON, YAML rendering."""
from __future__ import annotations
import json
import pytest
from pydantic import BaseModel
from opensandbox_cli.output import OutputFormatter
# ---------------------------------------------------------------------------
# Test models
# ---------------------------------------------------------------------------
class FakeItem(BaseModel):
id: str
name: str
score: int
# ---------------------------------------------------------------------------
# JSON output
# ---------------------------------------------------------------------------
class TestJsonOutput:
def test_print_dict(self, capsys: pytest.CaptureFixture[str]) -> None:
fmt = OutputFormatter("json", color=False)
fmt.print_dict({"key": "value", "num": 42})
captured = capsys.readouterr()
data = json.loads(captured.out)
assert data == {"key": "value", "num": 42}
def test_print_model(self, capsys: pytest.CaptureFixture[str]) -> None:
fmt = OutputFormatter("json", color=False)
item = FakeItem(id="abc", name="test", score=100)
fmt.print_model(item)
captured = capsys.readouterr()
data = json.loads(captured.out)
assert data["id"] == "abc"
assert data["name"] == "test"
assert data["score"] == 100
def test_print_models(self, capsys: pytest.CaptureFixture[str]) -> None:
fmt = OutputFormatter("json", color=False)
items = [
FakeItem(id="1", name="a", score=10),
FakeItem(id="2", name="b", score=20),
]
fmt.print_models(items, columns=["id", "name", "score"])
captured = capsys.readouterr()
data = json.loads(captured.out)
assert len(data) == 2
assert data[0]["id"] == "1"
assert data[1]["name"] == "b"
def test_success_renders_structured_json(self, capsys: pytest.CaptureFixture[str]) -> None:
fmt = OutputFormatter("json", color=False)
fmt.success("done")
captured = capsys.readouterr()
data = json.loads(captured.out)
assert data == {"status": "ok", "message": "done"}
# ---------------------------------------------------------------------------
# YAML output
# ---------------------------------------------------------------------------
class TestYamlOutput:
def test_print_dict(self, capsys: pytest.CaptureFixture[str]) -> None:
fmt = OutputFormatter("yaml", color=False)
fmt.print_dict({"key": "value"})
captured = capsys.readouterr()
assert "key: value" in captured.out
def test_print_model(self, capsys: pytest.CaptureFixture[str]) -> None:
fmt = OutputFormatter("yaml", color=False)
item = FakeItem(id="x", name="y", score=5)
fmt.print_model(item)
captured = capsys.readouterr()
assert "id: x" in captured.out
assert "name: y" in captured.out
assert "score: 5" in captured.out
# ---------------------------------------------------------------------------
# Table output
# ---------------------------------------------------------------------------
class TestTableOutput:
def test_print_dict_contains_values(self, capsys: pytest.CaptureFixture[str]) -> None:
fmt = OutputFormatter("table", color=False)
fmt.print_dict({"host": "example.com", "port": 8080}, title="Config")
captured = capsys.readouterr()
assert any(cell == "example.com" for cell in captured.out.split())
assert "8080" in captured.out
assert "Config" in captured.out
def test_print_dict_none_renders_dash(self, capsys: pytest.CaptureFixture[str]) -> None:
fmt = OutputFormatter("table", color=False)
fmt.print_dict({"key": None})
captured = capsys.readouterr()
assert "-" in captured.out
def test_print_models_shows_headers(self, capsys: pytest.CaptureFixture[str]) -> None:
fmt = OutputFormatter("table", color=False)
items = [FakeItem(id="1", name="a", score=10)]
fmt.print_models(items, columns=["id", "name", "score"], title="Items")
captured = capsys.readouterr()
assert "ID" in captured.out
assert "NAME" in captured.out
assert "SCORE" in captured.out
def test_print_text_ignores_format(self, capsys: pytest.CaptureFixture[str]) -> None:
fmt = OutputFormatter("json", color=False)
fmt.print_text("hello world")
captured = capsys.readouterr()
assert captured.out.strip() == "hello world"
+671
View File
@@ -0,0 +1,671 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for bundled skill install/list/show/uninstall flows."""
from __future__ import annotations
import importlib.resources
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from click import Command, Group, Option
from click.testing import CliRunner
from opensandbox_cli.commands.skills import _TARGETS, skills_group
from opensandbox_cli.main import cli
from opensandbox_cli.output import OutputFormatter
from opensandbox_cli.skill_registry import list_builtin_skills
@pytest.fixture()
def isolated_skill_targets(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
patched = {
"claude": {
**_TARGETS["claude"],
"scopes": {
"project": {
**_TARGETS["claude"]["scopes"]["project"],
"dest_dir": tmp_path / ".claude" / "skills",
},
"global": {
**_TARGETS["claude"]["scopes"]["global"],
"dest_dir": tmp_path / "home" / ".claude" / "skills",
},
},
},
"cursor": {
**_TARGETS["cursor"],
"scopes": {
"project": {
**_TARGETS["cursor"]["scopes"]["project"],
"dest_dir": tmp_path / ".cursor" / "rules",
},
"global": {
**_TARGETS["cursor"]["scopes"]["global"],
"dest_dir": tmp_path / "home" / ".cursor" / "rules",
},
},
},
"codex": {
**_TARGETS["codex"],
"scopes": {
"project": {
**_TARGETS["codex"]["scopes"]["project"],
"dest_dir": tmp_path / ".codex" / "skills",
},
"global": {
**_TARGETS["codex"]["scopes"]["global"],
"dest_dir": tmp_path / "home" / ".codex" / "skills",
},
},
},
"copilot": {
**_TARGETS["copilot"],
"scopes": {
"project": {
**_TARGETS["copilot"]["scopes"]["project"],
"dest_file": tmp_path / ".github" / "copilot-instructions.md",
},
"global": {
**_TARGETS["copilot"]["scopes"]["global"],
"dest_file": tmp_path / "home" / ".github" / "copilot-instructions.md",
},
},
},
"windsurf": {
**_TARGETS["windsurf"],
"scopes": {
"project": {
**_TARGETS["windsurf"]["scopes"]["project"],
"dest_file": tmp_path / ".windsurfrules",
},
"global": {
**_TARGETS["windsurf"]["scopes"]["global"],
"dest_file": tmp_path / "home" / ".windsurfrules",
},
},
},
"cline": {
**_TARGETS["cline"],
"scopes": {
"project": {
**_TARGETS["cline"]["scopes"]["project"],
"dest_file": tmp_path / ".clinerules",
},
"global": {
**_TARGETS["cline"]["scopes"]["global"],
"dest_file": tmp_path / "home" / ".clinerules",
},
},
},
"opencode": {
**_TARGETS["opencode"],
"scopes": {
"project": {
**_TARGETS["opencode"]["scopes"]["project"],
"dest_dir": tmp_path / ".agents" / "skills",
},
"global": {
**_TARGETS["opencode"]["scopes"]["global"],
"dest_dir": tmp_path / "home" / ".agents" / "skills",
},
},
},
}
monkeypatch.setattr("opensandbox_cli.commands.skills._TARGETS", patched)
class TestSkillsCommands:
def test_list_supports_json_output(
self,
runner: CliRunner,
isolated_skill_targets: None,
) -> None:
result = runner.invoke(
skills_group,
["list"],
obj=SimpleNamespace(output=OutputFormatter("json", color=False)),
)
assert result.exit_code == 0
data = json.loads(result.output)
assert "skills" in data
assert "targets" in data
assert any(skill["slug"] == "sandbox-troubleshooting" for skill in data["skills"])
assert any(skill["slug"] == "sandbox-lifecycle" for skill in data["skills"])
def test_show_supports_json_output(
self,
runner: CliRunner,
isolated_skill_targets: None,
) -> None:
result = runner.invoke(
skills_group,
["show", "sandbox-lifecycle"],
obj=SimpleNamespace(output=OutputFormatter("json", color=False)),
)
assert result.exit_code == 0
data = json.loads(result.output)
assert data["skill"] == "sandbox-lifecycle"
assert data["title"] == "OpenSandbox Sandbox Lifecycle"
assert '"defaultAction": "deny"' in data["json_shapes"]
def test_install_without_args_prints_guidance_and_does_not_install(
self,
runner: CliRunner,
isolated_skill_targets: None,
tmp_path: Path,
) -> None:
result = runner.invoke(skills_group, ["install"])
assert result.exit_code != 0
assert "Install guidance:" in result.output
assert "osb skills install <skill-name> --target <tool> --scope <scope>" in result.output
assert not (tmp_path / ".claude" / "skills" / "sandbox-troubleshooting.md").exists()
def test_install_with_skill_but_without_target_prints_guidance(
self,
runner: CliRunner,
isolated_skill_targets: None,
) -> None:
result = runner.invoke(skills_group, ["install", "sandbox-troubleshooting"])
assert result.exit_code != 0
assert "Install guidance:" in result.output
assert "Missing required option '--target'" in result.output
def test_install_with_all_builtins_but_without_target_prints_guidance(
self,
runner: CliRunner,
isolated_skill_targets: None,
) -> None:
result = runner.invoke(skills_group, ["install", "--all-builtins"])
assert result.exit_code != 0
assert "Install guidance:" in result.output
assert "Missing required option '--target'" in result.output
def test_install_copy_target_creates_named_skill_file(
self,
runner: CliRunner,
isolated_skill_targets: None,
tmp_path: Path,
) -> None:
result = runner.invoke(
skills_group,
["install", "sandbox-troubleshooting", "--target", "claude", "--scope", "project"],
)
assert result.exit_code == 0
dest = tmp_path / ".claude" / "skills" / "sandbox-troubleshooting.md"
assert dest.exists()
content = dest.read_text(encoding="utf-8")
assert content.startswith("---\nname: sandbox-troubleshooting")
def test_install_codex_creates_skill_directory_with_frontmatter(
self,
runner: CliRunner,
isolated_skill_targets: None,
tmp_path: Path,
) -> None:
result = runner.invoke(
skills_group,
["install", "sandbox-troubleshooting", "--target", "codex", "--scope", "project"],
)
assert result.exit_code == 0
dest = tmp_path / ".codex" / "skills" / "sandbox-troubleshooting" / "SKILL.md"
content = dest.read_text(encoding="utf-8")
assert content.startswith("---\nname: sandbox-troubleshooting")
assert "# OpenSandbox Sandbox Troubleshooting" in content
def test_install_reports_already_present_without_prompt(
self,
runner: CliRunner,
isolated_skill_targets: None,
tmp_path: Path,
) -> None:
first = runner.invoke(
skills_group,
["install", "sandbox-troubleshooting", "--target", "codex", "--scope", "project"],
)
assert first.exit_code == 0
second = runner.invoke(
skills_group,
["install", "sandbox-troubleshooting", "--target", "codex", "--scope", "project"],
)
assert second.exit_code == 0
assert "already_present" in second.output
dest = tmp_path / ".codex" / "skills" / "sandbox-troubleshooting" / "SKILL.md"
assert dest.exists()
def test_install_all_builtins_to_codex_creates_skill_directories(
self,
runner: CliRunner,
isolated_skill_targets: None,
tmp_path: Path,
) -> None:
result = runner.invoke(
skills_group,
["install", "--all-builtins", "--target", "codex", "--scope", "project"],
)
assert result.exit_code == 0
assert "Install plan:" in result.output
assert "install one file per skill" in result.output
for skill in list_builtin_skills():
dest = tmp_path / ".codex" / "skills" / skill.slug / "SKILL.md"
assert dest.exists()
def test_install_supports_json_output(
self,
runner: CliRunner,
isolated_skill_targets: None,
) -> None:
result = runner.invoke(
skills_group,
["install", "network-egress", "--target", "codex", "--scope", "project"],
obj=SimpleNamespace(output=OutputFormatter("json", color=False)),
)
assert result.exit_code == 0
data = json.loads(result.output)
assert data["requires_restart"] is True
assert data["operations"][0]["skill"] == "network-egress"
assert data["operations"][0]["status"] == "installed"
def test_install_rejects_skill_name_and_all_builtins_together(
self,
runner: CliRunner,
isolated_skill_targets: None,
) -> None:
result = runner.invoke(
skills_group,
["install", "sandbox-troubleshooting", "--all-builtins"],
)
assert result.exit_code != 0
assert "either a skill name or --all-builtins" in result.output
def test_install_to_global_codex_uses_global_path(
self,
runner: CliRunner,
isolated_skill_targets: None,
tmp_path: Path,
) -> None:
result = runner.invoke(
skills_group,
["install", "sandbox-troubleshooting", "--target", "codex", "--scope", "global"],
)
assert result.exit_code == 0
assert (tmp_path / "home" / ".codex" / "skills" / "sandbox-troubleshooting" / "SKILL.md").exists()
def test_install_to_project_opencode_creates_skill_directory(
self,
runner: CliRunner,
isolated_skill_targets: None,
tmp_path: Path,
) -> None:
result = runner.invoke(
skills_group,
["install", "network-egress", "--target", "opencode", "--scope", "project"],
)
assert result.exit_code == 0
dest = tmp_path / ".agents" / "skills" / "network-egress" / "SKILL.md"
assert dest.exists()
assert dest.read_text(encoding="utf-8").startswith("---\nname: network-egress")
def test_show_prints_skill_metadata_and_content(
self,
runner: CliRunner,
isolated_skill_targets: None,
) -> None:
result = runner.invoke(skills_group, ["show", "file-operations"])
assert result.exit_code == 0
assert "Skill: file-operations" in result.output
assert "Title: OpenSandbox File Operations" in result.output
assert "When To Use:" in result.output
assert "Quick Start:" in result.output
assert "Minimal Closed Loops:" in result.output
assert "Full Skill:" in result.output
assert "osb file cat" in result.output
def test_show_supports_new_network_egress_skill(
self,
runner: CliRunner,
isolated_skill_targets: None,
) -> None:
result = runner.invoke(skills_group, ["show", "network-egress"])
assert result.exit_code == 0
assert "Skill: network-egress" in result.output
assert "Quick Start:" in result.output
assert "osb egress patch" in result.output
def test_show_supports_credential_vault_skill(
self,
runner: CliRunner,
isolated_skill_targets: None,
) -> None:
result = runner.invoke(skills_group, ["show", "credential-vault"])
assert result.exit_code == 0
assert "Skill: credential-vault" in result.output
assert "Credential Vault" in result.output
assert "osb credential-vault create" in result.output
def test_show_surfaces_json_shapes_for_lifecycle_skill(
self,
runner: CliRunner,
isolated_skill_targets: None,
) -> None:
result = runner.invoke(skills_group, ["show", "sandbox-lifecycle"])
assert result.exit_code == 0
assert "JSON Shapes:" in result.output
assert '"defaultAction": "deny"' in result.output
assert '"mountPath": "/workspace/data"' in result.output
def test_list_reports_all_builtins(
self,
runner: CliRunner,
isolated_skill_targets: None,
) -> None:
result = runner.invoke(skills_group, ["list"])
assert result.exit_code == 0
assert "aggregate into one instructions file" in result.output
assert "install one file per skill" in result.output
for skill in list_builtin_skills():
assert skill.slug in result.output
def test_list_reports_not_installed_when_append_target_file_exists_without_marker(
self,
runner: CliRunner,
isolated_skill_targets: None,
tmp_path: Path,
) -> None:
dest = tmp_path / ".github" / "copilot-instructions.md"
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text("user custom instructions\n", encoding="utf-8")
result = runner.invoke(skills_group, ["list"])
assert result.exit_code == 0
assert "copilot" in result.output
assert "not installed" in result.output
def test_uninstall_append_target_preserves_non_skill_content(
self,
runner: CliRunner,
isolated_skill_targets: None,
tmp_path: Path,
) -> None:
install_result = runner.invoke(
skills_group,
["install", "sandbox-troubleshooting", "--target", "copilot", "--scope", "project"],
)
assert install_result.exit_code == 0
dest = tmp_path / ".github" / "copilot-instructions.md"
original = dest.read_text(encoding="utf-8")
dest.write_text("team rules\n\n" + original, encoding="utf-8")
uninstall_result = runner.invoke(
skills_group,
["uninstall", "sandbox-troubleshooting", "--target", "copilot", "--scope", "project"],
)
assert uninstall_result.exit_code == 0
assert dest.read_text(encoding="utf-8") == "team rules\n"
def test_uninstall_supports_json_output(
self,
runner: CliRunner,
isolated_skill_targets: None,
) -> None:
install_result = runner.invoke(
skills_group,
["install", "sandbox-troubleshooting", "--target", "codex", "--scope", "project"],
)
assert install_result.exit_code == 0
uninstall_result = runner.invoke(
skills_group,
["uninstall", "sandbox-troubleshooting", "--target", "codex", "--scope", "project"],
obj=SimpleNamespace(output=OutputFormatter("json", color=False)),
)
assert uninstall_result.exit_code == 0
data = json.loads(uninstall_result.output)
assert data["operations"][0]["status"] == "removed"
def test_reinstall_append_target_does_not_duplicate_skill_block(
self,
runner: CliRunner,
isolated_skill_targets: None,
tmp_path: Path,
) -> None:
first = runner.invoke(
skills_group,
["install", "sandbox-troubleshooting", "--target", "copilot", "--scope", "project"],
)
assert first.exit_code == 0
second = runner.invoke(
skills_group,
["install", "sandbox-troubleshooting", "--target", "copilot", "--scope", "project", "--force"],
)
assert second.exit_code == 0
dest = tmp_path / ".github" / "copilot-instructions.md"
content = dest.read_text(encoding="utf-8")
assert content.count("<!-- BEGIN opensandbox-sandbox-troubleshooting -->") == 1
def test_install_all_builtins_to_copy_target_creates_new_skill_files(
self,
runner: CliRunner,
isolated_skill_targets: None,
tmp_path: Path,
) -> None:
result = runner.invoke(
skills_group,
["install", "--all-builtins", "--target", "claude", "--scope", "project"],
)
assert result.exit_code == 0
assert "Install plan:" in result.output
assert "install one file per skill" in result.output
assert (tmp_path / ".claude" / "skills" / "network-egress.md").exists()
assert (tmp_path / ".claude" / "skills" / "sandbox-troubleshooting.md").exists()
def _read_builtin_skill(package_file: str) -> str:
resource = importlib.resources.files("opensandbox_cli") / "skills" / package_file
return Path(str(resource)).read_text(encoding="utf-8")
def _command(path: list[str]) -> Command:
current: Command = cli
for part in path:
assert isinstance(current, Group), f"{current.name} is not a command group"
current = current.commands[part]
return current
def _option_names(command: Command) -> set[str]:
names: set[str] = set()
for param in command.params:
if isinstance(param, Option):
names.update(param.opts)
names.update(param.secondary_opts)
return names
class TestSkillContentQuality:
def test_sandbox_troubleshooting_keeps_triage_and_diagnostics_contract(self) -> None:
content = _read_builtin_skill("opensandbox-sandbox-troubleshooting.md")
assert "## Triage Order" in content
assert "osb sandbox get <sandbox-id> -o json" in content
assert "osb diagnostics events <sandbox-id> --scope lifecycle -o raw" in content
assert "osb diagnostics events <sandbox-id> --scope runtime -o raw" in content
assert "osb diagnostics logs <sandbox-id> --scope container -o raw" in content
assert "## Diagnostics Streams" in content
assert "## Evidence Semantics" in content
assert "## URL Delivery" in content
assert "truncated: true" in content
assert "osb devops" not in content
assert "## Symptom To Command Mapping" in content
def test_builtin_skills_do_not_reference_legacy_devops_diagnostics(self) -> None:
skills_dir = Path("src/opensandbox_cli/skills")
forbidden = (
"osb devops inspect",
"osb devops summary",
"devops inspect",
"devops summary",
)
violations: list[str] = []
for skill_path in sorted(skills_dir.glob("*.md")):
content = skill_path.read_text(encoding="utf-8")
for term in forbidden:
if term in content:
violations.append(f"{skill_path.name}: {term}")
assert violations == []
def test_lifecycle_skill_keeps_json_shapes_and_health_guidance(self) -> None:
content = _read_builtin_skill("opensandbox-sandbox-lifecycle.md")
assert "## JSON Shapes" in content
assert '"defaultAction": "deny"' in content
assert '"mountPath": "/workspace/data"' in content
assert "Prefer `health` over assuming readiness from `create` output alone" in content
def test_sandbox_troubleshooting_keeps_cli_first_and_http_fallback_guidance(self) -> None:
content = _read_builtin_skill("opensandbox-sandbox-troubleshooting.md")
assert "## Operating Rules" in content
assert "use CLI commands when `osb` is available" in content
assert "use HTTP only when the CLI is unavailable" in content
assert "Use raw HTTP only after domain, protocol, and API key expectations are explicit." in content
assert "## Symptom To Command Mapping" in content
class TestSkillCliAlignment:
def test_command_execution_skill_matches_command_cli(self) -> None:
run_cmd = _command(["command", "run"])
session_run_cmd = _command(["command", "session", "run"])
logs_cmd = _command(["command", "logs"])
session_delete_cmd = _command(["command", "session", "delete"])
assert {"-d", "--background", "-w", "--workdir", "-t", "--timeout", "-o", "--output"} <= _option_names(run_cmd)
assert {"-w", "--workdir", "-t", "--timeout", "-o", "--output"} <= _option_names(session_run_cmd)
assert {"--cursor", "-o", "--output"} <= _option_names(logs_cmd)
assert {"-o", "--output"} <= _option_names(session_delete_cmd)
def test_sandbox_lifecycle_skill_matches_sandbox_cli(self) -> None:
create_cmd = _command(["sandbox", "create"])
resume_cmd = _command(["sandbox", "resume"])
endpoint_cmd = _command(["sandbox", "endpoint"])
metrics_cmd = _command(["sandbox", "metrics"])
assert {
"-i", "--image", "-t", "--timeout", "--entrypoint", "--network-policy-file",
"--credential-proxy", "--volumes-file", "--skip-health-check",
"--ready-timeout", "-o", "--output",
} <= _option_names(create_cmd)
assert {"--skip-health-check", "--resume-timeout", "-o", "--output"} <= _option_names(resume_cmd)
assert {"-p", "--port", "-o", "--output"} <= _option_names(endpoint_cmd)
assert {"--watch", "-o", "--output"} <= _option_names(metrics_cmd)
def test_file_operations_skill_matches_file_cli(self) -> None:
expected_subcommands = {
"cat", "write", "upload", "download", "rm", "mv", "mkdir",
"rmdir", "search", "info", "chmod", "replace",
}
file_group = _command(["file"])
assert isinstance(file_group, Group)
assert expected_subcommands <= set(file_group.commands)
assert {"-c", "--content", "--encoding", "--mode", "--owner", "--group", "-o", "--output"} <= _option_names(
_command(["file", "write"])
)
assert {"-p", "--pattern", "-o", "--output"} <= _option_names(_command(["file", "search"]))
assert {"--mode", "--owner", "--group", "-o", "--output"} <= _option_names(_command(["file", "chmod"]))
assert {"--old", "--new", "-o", "--output"} <= _option_names(_command(["file", "replace"]))
def test_network_egress_diagnostics_and_devops_skills_match_cli(self) -> None:
patch_cmd = _command(["egress", "patch"])
diag_logs_cmd = _command(["diagnostics", "logs"])
diag_events_cmd = _command(["diagnostics", "events"])
logs_cmd = _command(["devops", "logs"])
events_cmd = _command(["devops", "events"])
summary_cmd = _command(["devops", "summary"])
assert {"--rule", "-o", "--output"} <= _option_names(patch_cmd)
assert {"--scope", "-s", "-o", "--output"} <= _option_names(diag_logs_cmd)
assert {"--scope", "-s", "-o", "--output"} <= _option_names(diag_events_cmd)
assert {"--tail", "-n", "--since", "-s", "-o", "--output"} <= _option_names(logs_cmd)
assert {"--limit", "-l", "-o", "--output"} <= _option_names(events_cmd)
assert {"--tail", "-n", "--event-limit", "-o", "--output"} <= _option_names(summary_cmd)
def test_credential_vault_skill_matches_cli(self) -> None:
vault_group = _command(["credential-vault"])
assert isinstance(vault_group, Group)
assert {"create", "get", "patch", "delete", "credential", "binding"} <= set(vault_group.commands)
assert {"--file", "-o", "--output"} <= _option_names(_command(["credential-vault", "create"]))
assert {"--file", "-o", "--output"} <= _option_names(_command(["credential-vault", "patch"]))
assert {"-o", "--output"} <= _option_names(_command(["credential-vault", "get"]))
assert {"-o", "--output"} <= _option_names(_command(["credential-vault", "delete"]))
assert {"-o", "--output"} <= _option_names(_command(["credential-vault", "credential", "list"]))
assert {"-o", "--output"} <= _option_names(_command(["credential-vault", "binding", "get"]))
def test_skill_osb_examples_use_explicit_output_formats(self) -> None:
allowed_without_output = {
"osb --version",
}
skills_dir = Path("src/opensandbox_cli/skills")
missing_output: list[str] = []
for skill_path in sorted(skills_dir.glob("*.md")):
in_block = False
for line in skill_path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped.startswith("```bash"):
in_block = True
continue
if in_block and stripped == "```":
in_block = False
continue
if not in_block or not stripped.startswith("osb "):
continue
if stripped in allowed_without_output:
continue
if " -o " not in stripped:
missing_output.append(f"{skill_path.name}: {stripped}")
assert missing_output == []
+108
View File
@@ -0,0 +1,108 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for opensandbox_cli.utils — duration parsing, key-value type, error handling."""
from __future__ import annotations
from datetime import timedelta
import click
import pytest
from opensandbox_cli.utils import DURATION, KEY_VALUE, parse_duration
# ---------------------------------------------------------------------------
# parse_duration
# ---------------------------------------------------------------------------
class TestParseDuration:
@pytest.mark.parametrize(
"input_str, expected",
[
("10", timedelta(seconds=10)),
("0", timedelta(seconds=0)),
("10s", timedelta(seconds=10)),
("5m", timedelta(minutes=5)),
("2h", timedelta(hours=2)),
("1h30m", timedelta(hours=1, minutes=30)),
("1h30m45s", timedelta(hours=1, minutes=30, seconds=45)),
("90s", timedelta(seconds=90)),
],
)
def test_valid_durations(self, input_str: str, expected: timedelta) -> None:
assert parse_duration(input_str) == expected
@pytest.mark.parametrize(
"input_str",
[
"",
"abc",
"10x",
"m10",
"-5m",
],
)
def test_invalid_durations(self, input_str: str) -> None:
with pytest.raises(click.BadParameter):
parse_duration(input_str)
def test_strips_whitespace(self) -> None:
assert parse_duration(" 10m ") == timedelta(minutes=10)
# ---------------------------------------------------------------------------
# DurationType (Click param type)
# ---------------------------------------------------------------------------
class TestDurationType:
def test_converts_string(self) -> None:
result = DURATION.convert("5m", None, None)
assert result == timedelta(minutes=5)
def test_passes_through_timedelta(self) -> None:
td = timedelta(hours=1)
result = DURATION.convert(td, None, None) # type: ignore[arg-type]
assert result is td
def test_invalid_raises_bad_parameter(self) -> None:
with pytest.raises(click.exceptions.BadParameter):
DURATION.convert("invalid", None, None)
# ---------------------------------------------------------------------------
# KeyValueType (Click param type)
# ---------------------------------------------------------------------------
class TestKeyValueType:
def test_parses_simple_kv(self) -> None:
assert KEY_VALUE.convert("FOO=bar", None, None) == ("FOO", "bar")
def test_value_can_contain_equals(self) -> None:
assert KEY_VALUE.convert("key=a=b=c", None, None) == ("key", "a=b=c")
def test_empty_value(self) -> None:
assert KEY_VALUE.convert("key=", None, None) == ("key", "")
def test_missing_equals_fails(self) -> None:
with pytest.raises(click.exceptions.BadParameter):
KEY_VALUE.convert("no-equals", None, None)
def test_passes_through_tuple(self) -> None:
t = ("key", "val")
result = KEY_VALUE.convert(t, None, None) # type: ignore[arg-type]
assert result is t
Generated
+751
View File
@@ -0,0 +1,751 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "attrs"
version = "25.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.13.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" },
{ url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" },
{ url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" },
{ url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" },
{ url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" },
{ url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" },
{ url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" },
{ url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" },
{ url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" },
{ url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" },
{ url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" },
{ url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" },
{ url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" },
{ url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" },
{ url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" },
{ url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" },
{ url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" },
{ url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" },
{ url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" },
{ url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" },
{ url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" },
{ url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" },
{ url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" },
{ url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" },
{ url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" },
{ url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" },
{ url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" },
{ url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" },
{ url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" },
{ url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" },
{ url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" },
{ url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" },
{ url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" },
{ url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" },
{ url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" },
{ url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" },
{ url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" },
{ url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" },
{ url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" },
{ url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" },
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
]
[package.optional-dependencies]
toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]]
name = "opensandbox"
source = { editable = "../sdks/sandbox/python" }
dependencies = [
{ name = "attrs" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "python-dateutil" },
]
[package.metadata]
requires-dist = [
{ name = "attrs", specifier = ">=21.3.0" },
{ name = "httpx", specifier = ">=0.27.0,<1.0" },
{ name = "pydantic", specifier = ">=2.4.2,<3.0" },
{ name = "python-dateutil", specifier = ">=2.8.2,<3.0" },
{ name = "redis", marker = "extra == 'pool-redis'", specifier = ">=5.0,<6.0" },
]
provides-extras = ["pool-redis"]
[package.metadata.requires-dev]
dev = [
{ name = "openapi-python-client", specifier = ">=0.28.0" },
{ name = "pyright", specifier = ">=1.1.0" },
{ name = "pytest", specifier = ">=7.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.21.0" },
{ name = "pytest-cov", specifier = ">=4.0.0" },
{ name = "redis", specifier = ">=5.0,<6.0" },
{ name = "ruff", specifier = ">=0.14.8" },
]
[[package]]
name = "opensandbox-cli"
source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "opensandbox" },
{ name = "pyyaml" },
{ name = "rich" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
[package.dev-dependencies]
dev = [
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.1.0,<9.0" },
{ name = "opensandbox", editable = "../sdks/sandbox/python" },
{ name = "pyyaml", specifier = ">=6.0,<7.0" },
{ name = "rich", specifier = ">=13.0.0,<14.0" },
{ name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pyright", specifier = ">=1.1.0" },
{ name = "pytest", specifier = ">=7.0.0" },
{ name = "pytest-cov", specifier = ">=4.0.0" },
{ name = "ruff", specifier = ">=0.14.8" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" },
{ url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" },
{ url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" },
{ url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" },
{ url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" },
{ url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" },
{ url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" },
{ url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" },
{ url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" },
{ url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" },
{ url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" },
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" },
{ url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" },
{ url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" },
{ url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" },
{ url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" },
{ url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" },
{ url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyright"
version = "1.1.408"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage", extra = ["toml"] },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "rich"
version = "13.9.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" },
]
[[package]]
name = "ruff"
version = "0.15.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" },
{ url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" },
{ url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" },
{ url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" },
{ url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" },
{ url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" },
{ url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" },
{ url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" },
{ url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" },
{ url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" },
{ url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" },
{ url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" },
{ url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "tomli"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" },
{ url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" },
{ url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" },
{ url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" },
{ url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" },
{ url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" },
{ url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" },
{ url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" },
{ url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" },
{ url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" },
{ url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" },
{ url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" },
{ url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" },
{ url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" },
{ url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" },
{ url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" },
{ url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" },
{ url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" },
{ url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" },
{ url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" },
{ url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" },
{ url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" },
{ url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" },
{ url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
{ url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
{ url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
{ url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
{ url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
{ url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
{ url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
{ url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
{ url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
{ url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
{ url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
{ url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
{ url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
{ url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
{ url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
{ url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
{ url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
{ url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
{ url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
+149
View File
@@ -0,0 +1,149 @@
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
FROM golang:1.25.9-bookworm AS builder
WORKDIR /workspace
ARG VERSION=dev
ARG GIT_COMMIT=unknown
ARG BUILD_TIME=unknown
ARG GOFLAGS=
ARG LDFLAGS=
ARG CGO_ENABLED=0
ARG CC=
ARG CXX=
ARG CFLAGS=
ARG CXXFLAGS=
ARG CGO_CFLAGS=
ARG CGO_CXXFLAGS=
ARG CGO_LDFLAGS=
# Copy only go mod/sum first for better caching
COPY components/egress/go.mod components/egress/go.sum ./components/egress/
# Bring internal module so replace ../internal works during download/build
COPY components/internal ./components/internal
WORKDIR /workspace/components/egress
# Static-ish build (no cgo by default) to simplify runtime deps.
RUN go mod download
# Pre-download internal-module deps for the supervisor build below.
RUN cd /workspace/components/internal && go mod download
# Copy the rest of the egress sources
COPY components/egress ./
RUN if [ -n "${CC}" ]; then export CC; fi; \
if [ -n "${CXX}" ]; then export CXX; fi; \
export CGO_ENABLED="${CGO_ENABLED}" \
CGO_CFLAGS="${CGO_CFLAGS:-${CFLAGS}}" \
CGO_CXXFLAGS="${CGO_CXXFLAGS:-${CXXFLAGS}}" \
CGO_LDFLAGS="${CGO_LDFLAGS}"; \
go build ${GOFLAGS} -trimpath -buildvcs=false \
-ldflags "${LDFLAGS} -buildid= -B none \
-X 'github.com/alibaba/opensandbox/internal/version.Version=${VERSION}' \
-X 'github.com/alibaba/opensandbox/internal/version.BuildTime=${BUILD_TIME}' \
-X 'github.com/alibaba/opensandbox/internal/version.GitCommit=${GIT_COMMIT}'" \
-o /out/egress .
# Build the opensandbox-supervisor binary from the internal module.
# Installed alongside /egress so a future ENTRYPOINT switch can wrap egress
# without changing this stage again.
RUN cd /workspace/components/internal && \
if [ -n "${CC}" ]; then export CC; fi; \
if [ -n "${CXX}" ]; then export CXX; fi; \
export CGO_ENABLED="${CGO_ENABLED}" \
CGO_CFLAGS="${CGO_CFLAGS:-${CFLAGS}}" \
CGO_CXXFLAGS="${CGO_CXXFLAGS:-${CXXFLAGS}}" \
CGO_LDFLAGS="${CGO_LDFLAGS}"; \
go build ${GOFLAGS} -trimpath -buildvcs=false \
-ldflags "${LDFLAGS} -buildid= -B none \
-X 'github.com/alibaba/opensandbox/internal/version.Version=${VERSION}' \
-X 'github.com/alibaba/opensandbox/internal/version.BuildTime=${BUILD_TIME}' \
-X 'github.com/alibaba/opensandbox/internal/version.GitCommit=${GIT_COMMIT}'" \
-o /out/opensandbox-supervisor ./cmd/supervisor
FROM debian:bookworm-slim
# iptables is needed for DNS REDIRECT; ca-certificates for TLS to upstream resolvers
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
iptables \
iproute2 \
nftables \
ca-certificates \
sudo \
curl \
wget \
net-tools \
dnsutils \
netcat-openbsd \
iputils-ping \
traceroute \
telnet \
tcpdump \
nmap \
htop \
procps \
strace \
lsof \
python3 \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
# Python mitmproxy (transparent mode): mitmdump runs as user mitmproxy; iptables skips this uid.
# /var/lib/mitmproxy is mitm's home, used as the confdir (CA + config.yaml live under .mitmproxy/).
RUN useradd -r -u 10042 -d /var/lib/mitmproxy -s /usr/sbin/nologin mitmproxy \
&& mkdir -p /var/lib/mitmproxy/.mitmproxy \
&& chown -R mitmproxy:mitmproxy /var/lib/mitmproxy \
&& pip3 install --no-cache-dir --break-system-packages 'mitmproxy>=10,<11' \
&& (command -v mitmdump && mitmdump --version) \
&& mkdir -p /var/egress/mitmscripts
# Static mitmproxy options (mode, listen_host, connection_strategy, stream_large_bodies,
# http2, ignore_hosts, ssl_verify_upstream_trusted_confdir). mitmdump auto-loads
# config.yaml from its confdir. Dynamic per-deployment options stay env-driven and
# are applied as --set by launch.go (which overrides values declared here).
COPY components/egress/mitmproxy/config.yaml /var/lib/mitmproxy/.mitmproxy/config.yaml
RUN chown mitmproxy:mitmproxy /var/lib/mitmproxy/.mitmproxy/config.yaml \
&& chmod 0644 /var/lib/mitmproxy/.mitmproxy/config.yaml
# All egress runtime artifacts live under one directory to keep paths grouped.
COPY --from=builder /out/egress /opt/opensandbox-egress/egress
COPY --from=builder /out/opensandbox-supervisor /opt/opensandbox-egress/supervisor
# Pre-start hook: reap any mitmdump left over from a previous crashed
# egress so the new launch can bind the transparent-MITM listen port.
# Intentionally does NOT touch iptables/nft rules — the sidecar shares
# a network namespace with the workload, so leaving rules in place keeps
# egress filtering active across the supervisor's backoff window.
COPY components/egress/scripts/cleanup.sh /opt/opensandbox-egress/cleanup.sh
RUN chmod 0755 /opt/opensandbox-egress/cleanup.sh \
/opt/opensandbox-egress/egress \
/opt/opensandbox-egress/supervisor \
&& ln -s /opt/opensandbox-egress/egress /egress
COPY components/egress/mitmscripts /var/egress/mitmscripts
# Supervisor wraps the egress binary: restarts on crash with backoff and
# forwards SIGTERM gracefully. The cleanup hook runs only as pre-start;
# running it on post-exit would tear down enforcement during the backoff
# window and leave the workload unprotected.
# Expects OPENSANDBOX_NETWORK_POLICY env at runtime.
ENTRYPOINT ["/opt/opensandbox-egress/supervisor", \
"--pre-start=/opt/opensandbox-egress/cleanup.sh", \
"--name=egress", \
"--grace-period=20s", \
"--", \
"/opt/opensandbox-egress/egress"]
@@ -0,0 +1,5 @@
**
!components/egress/**
!components/internal/**
!egress/**
!internal/**
+4
View File
@@ -0,0 +1,4 @@
# OpenSandbox Egress
Documentation: [docs/components/egress.md](../../docs/components/egress.md)
+89
View File
@@ -0,0 +1,89 @@
# components/egress 1.0.4
## What's New
### ✨ Features
- persist egress policy to local file by env `OPENSANDBOX_EGRESS_POLICY_FILE` (#525)
### 🐛 Bug Fixes
- Clamp nft element timeouts to 60360s (was 60300s) so the upper bound still matches a 300s DNS TTL cap plus slack; raise dyn_allow_* set timeout to 360s (#595)
- Avoid nft "conflicting intervals" when static allow/deny lists overlap (e.g. CGNAT + host inside). Add normalizeNFTIntervalSet to drop strictly contained prefixes before add element (#595)
## 👥 Contributors
Thanks to these contributors ❤️
- @Pangjiping
---
- Docker Hub: opensandbox/egress:v1.0.4
- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:v1.0.4
# components/egress 1.0.3
## What's New
### ✨ Features
- add denied hostname webhook fanout (#406)
- add sandboxID within deny webhook payload (#427)
### 📦 Misc
- install network tools, like ip (#427)
- refactor test by testify framework (#427)
## 👥 Contributors
Thanks to these contributors ❤️
- @Pangjiping
---
- Docker Hub: opensandbox/egress:v1.0.3
- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:v1.0.3
# components/egress 1.0.2
## What's New
### ✨ Features
- add patch policy updates and somke coverage (#392)
- add nameserver exempt for direct DNS forwarding (#356)
### 📦 Misc
- sync latest image for v-prefixed TAG (#331)
- Potential fix for code scanning alert no. 114: Workflow does not contain permissions (#278)
## 👥 Contributors
Thanks to these contributors ❤️
- @Pangjiping
---
- Docker Hub: opensandbox/egress:v1.0.2
- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:v1.0.2
# components/egress 1.0.1
## What's New
### ✨ Features
- Egress stage two for IP/CIDR rules, DoT/DoH block (#183)
- Egress stage three for dynamic IP insertion from DNS answers (#197)
- unified logger by internal package (#244)
- print build/compile info when start up (#245)
### 📦 Misc
- chore(deps): bump golang.org/x/net from 0.26.0 to 0.38.0 in /components/egress (#192)
## 👥 Contributors
Thanks to these contributors ❤️
- @Pangjiping
- @dependabot
---
- Docker Hub: opensandbox/egress:v1.0.1
- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:v1.0.1
+76
View File
@@ -0,0 +1,76 @@
#!/bin/bash
# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
set -ex
default_build_time() {
if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then
date -u -d "@${SOURCE_DATE_EPOCH}" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null ||
date -u -r "${SOURCE_DATE_EPOCH}" +"%Y-%m-%dT%H:%M:%SZ"
else
date -u +"%Y-%m-%dT%H:%M:%SZ"
fi
}
build_arg_if_set() {
local name="$1"
if [[ -n "${!name+x}" ]]; then
BUILD_ARGS+=(--build-arg "${name}=${!name}")
fi
}
TAG=${TAG:-latest}
GHCR_REPO=${GHCR_REPO:-}
VERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}
GIT_COMMIT=${GIT_COMMIT:-$(git rev-parse HEAD 2>/dev/null || echo "unknown")}
BUILD_TIME=${BUILD_TIME:-$(default_build_time)}
BUILD_METADATA_FILE=${BUILD_METADATA_FILE:-build/egress-image-metadata.json}
BUILD_ARGS=()
for name in GOFLAGS LDFLAGS CGO_ENABLED CC CXX CFLAGS CXXFLAGS CGO_CFLAGS CGO_CXXFLAGS CGO_LDFLAGS; do
build_arg_if_set "${name}"
done
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || realpath "$(dirname "$0")/../..")
cd "${REPO_ROOT}"
mkdir -p "$(dirname "${BUILD_METADATA_FILE}")"
docker buildx rm egress-builder || true
docker buildx create --use --name egress-builder
docker buildx inspect --bootstrap
docker buildx ls
IMAGE_TAGS=(-t opensandbox/egress:${TAG} -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:${TAG})
LATEST_TAGS=()
if [[ -n "${GHCR_REPO}" ]]; then
IMAGE_TAGS+=(-t "${GHCR_REPO}/egress:${TAG}")
fi
if [[ "${TAG}" == v* ]]; then
LATEST_TAGS+=(-t opensandbox/egress:latest -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:latest)
if [[ -n "${GHCR_REPO}" ]]; then
LATEST_TAGS+=(-t "${GHCR_REPO}/egress:latest")
fi
fi
docker buildx build \
"${IMAGE_TAGS[@]}" \
"${LATEST_TAGS[@]}" \
-f components/egress/Dockerfile \
"${BUILD_ARGS[@]}" \
--build-arg VERSION="${VERSION}" \
--build-arg GIT_COMMIT="${GIT_COMMIT}" \
--build-arg BUILD_TIME="${BUILD_TIME}" \
--platform linux/amd64,linux/arm64 \
--metadata-file "${BUILD_METADATA_FILE}" \
--push \
.
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestCleanupScriptRemovesNativeDNSRedirectFallbackTables(t *testing.T) {
tmpDir := t.TempDir()
binDir := filepath.Join(tmpDir, "bin")
require.NoError(t, os.Mkdir(binDir, 0o755))
logPath := filepath.Join(tmpDir, "nft.log")
orderPath := filepath.Join(tmpDir, "order.log")
writeExecutable(t, filepath.Join(binDir, "nft"), `#!/bin/sh
printf '%s\n' "$*" >> "`+logPath+`"
cat >> "`+logPath+`"
printf 'nft\n' >> "`+orderPath+`"
exit 0
`)
writeExecutable(t, filepath.Join(binDir, "iptables"), `#!/bin/sh
printf 'iptables\n' >> "`+orderPath+`"
exit 0
`)
writeExecutable(t, filepath.Join(binDir, "ip6tables"), `#!/bin/sh
printf 'ip6tables\n' >> "`+orderPath+`"
exit 0
`)
writeExecutable(t, filepath.Join(binDir, "pkill"), `#!/bin/sh
exit 0
`)
cmd := exec.Command("sh", "scripts/cleanup.sh")
cmd.Env = append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
out, err := cmd.CombinedOutput()
require.NoError(t, err, string(out))
logBytes, err := os.ReadFile(logPath)
require.NoError(t, err)
logText := string(logBytes)
require.Contains(t, logText, "delete table inet opensandbox_dns_redirect")
require.Contains(t, logText, "delete table ip opensandbox_dns_redirect")
require.Contains(t, logText, "delete table ip6 opensandbox_dns_redirect")
orderBytes, err := os.ReadFile(orderPath)
require.NoError(t, err)
order := strings.Split(strings.TrimSpace(string(orderBytes)), "\n")
require.NotEmpty(t, order)
require.Equal(t, "nft", order[0])
}
func writeExecutable(t *testing.T, path string, content string) {
t.Helper()
require.NoError(t, os.WriteFile(path, []byte(strings.TrimLeft(content, "\n")), 0o755))
}
@@ -0,0 +1,307 @@
// Copyright 2026 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/alibaba/opensandbox/egress/pkg/constants"
"github.com/alibaba/opensandbox/egress/pkg/credentialvault"
"github.com/alibaba/opensandbox/egress/pkg/policy"
"github.com/stretchr/testify/require"
)
func testCredentialVaultPolicy(t *testing.T, raw string) *policy.NetworkPolicy {
t.Helper()
pol, err := policy.ParsePolicy(raw)
require.NoError(t, err)
return pol
}
func testCredentialVaultRequest() credentialvault.CreateRequest {
return credentialvault.CreateRequest{
Credentials: []credentialvault.Credential{
{
Name: "gitlab-token",
Source: json.RawMessage(`{"type":"inline","value":"secret-token"}`),
},
},
Bindings: []credentialvault.Binding{
{
Name: "gitlab-api",
Match: credentialvault.Match{
Hosts: []string{"code.example.com"},
Methods: []string{"GET"},
Paths: []string{"/api/v8/*"},
},
Auth: credentialvault.Auth{
Type: "apiKey",
Name: "PRIVATE-TOKEN",
Credential: "gitlab-token",
},
},
},
}
}
func TestCredentialVaultActiveTCPAlwaysForbidden(t *testing.T) {
store := credentialvault.NewStore(nil, func() bool { return true })
pol := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`)
_, err := store.Create(testCredentialVaultRequest(), pol)
require.NoError(t, err)
srv := &policyServer{
token: "public-egress-token",
credentialVault: store,
}
req := httptest.NewRequest(http.MethodGet, "/credential-vault/_active", nil)
req.RemoteAddr = "127.0.0.1:4321"
req.Header.Set(constants.EgressAuthTokenHeader, "public-egress-token")
w := httptest.NewRecorder()
srv.handleCredentialVaultSubresource(w, req)
require.Equal(t, http.StatusForbidden, w.Result().StatusCode)
require.NotContains(t, w.Body.String(), "secret-token")
}
func TestCredentialVaultActiveUnixSocketReturnsSnapshot(t *testing.T) {
store := credentialvault.NewStore(nil, func() bool { return true })
pol := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`)
_, err := store.Create(testCredentialVaultRequest(), pol)
require.NoError(t, err)
srv := &policyServer{
token: "public-egress-token",
credentialVault: store,
}
tmpDir, err := os.MkdirTemp("/tmp", "opensandbox-active-*")
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, os.RemoveAll(tmpDir))
})
socketPath := filepath.Join(tmpDir, "credential-proxy", "active.sock")
_, cleanup, err := credentialvault.StartActiveSocketServer(srv.handleCredentialVaultActive, socketPath, -1)
require.NoError(t, err)
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
require.NoError(t, cleanup(ctx))
})
client := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var dialer net.Dialer
return dialer.DialContext(ctx, "unix", socketPath)
},
},
}
resp, err := client.Get("http://credential-proxy/credential-vault/_active")
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Contains(t, string(body), "secret-token")
require.Contains(t, string(body), "Private-Token")
}
func TestCredentialVaultActiveBindingBlocksEgressPolicyRemoval(t *testing.T) {
initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`)
proxy := &stubProxy{updated: initial}
nft := &stubNft{}
store := credentialvault.NewStore(nil, func() bool { return true })
_, err := store.Create(testCredentialVaultRequest(), initial)
require.NoError(t, err)
srv := &policyServer{
proxy: proxy,
nft: nft,
enforcementMode: "dns+nft",
credentialVault: store,
}
req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(`["code.example.com"]`))
w := httptest.NewRecorder()
srv.handlePolicy(w, req)
require.Equal(t, http.StatusBadRequest, w.Result().StatusCode)
require.Len(t, proxy.updated.Egress, 1)
require.Equal(t, 0, nft.calls)
}
func TestCredentialVaultActiveBindingBlocksPolicyReset(t *testing.T) {
initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`)
proxy := &stubProxy{updated: initial}
nft := &stubNft{}
store := credentialvault.NewStore(nil, func() bool { return true })
_, err := store.Create(testCredentialVaultRequest(), initial)
require.NoError(t, err)
srv := &policyServer{
proxy: proxy,
nft: nft,
enforcementMode: "dns+nft",
credentialVault: store,
}
req := httptest.NewRequest(http.MethodPost, "/policy", strings.NewReader(""))
w := httptest.NewRecorder()
srv.handlePolicy(w, req)
require.Equal(t, http.StatusBadRequest, w.Result().StatusCode)
require.Contains(t, w.Body.String(), "credential vault policy validation")
require.Len(t, proxy.updated.Egress, 1)
require.Equal(t, 0, nft.calls)
}
func TestCredentialVaultDeleteRequiresReady(t *testing.T) {
t.Setenv(constants.EnvMitmproxyTransparent, "")
srv := &policyServer{
credentialVault: credentialvault.NewStore(nil, func() bool { return true }),
}
req := httptest.NewRequest(http.MethodDelete, "/credential-vault", nil)
req.RemoteAddr = "127.0.0.1:4321"
w := httptest.NewRecorder()
srv.handleCredentialVault(w, req)
require.Equal(t, http.StatusPreconditionFailed, w.Result().StatusCode)
require.Contains(t, w.Body.String(), "transparent mitmproxy")
}
func TestCredentialVaultWriteRequiresTLSOrLoopback(t *testing.T) {
t.Setenv(constants.EnvMitmproxyTransparent, "true")
t.Setenv(constants.EnvEgressMode, constants.PolicyDnsNft)
initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`)
srv := &policyServer{
proxy: &stubProxy{updated: initial},
credentialVault: credentialvault.NewStore(nil, func() bool { return true }),
credentialVaultRequireTLS: true,
}
req := httptest.NewRequest(http.MethodPost, "/credential-vault", strings.NewReader(`{"credentials":[],"bindings":[]}`))
req.RemoteAddr = "198.51.100.10:1234"
w := httptest.NewRecorder()
srv.handleCredentialVault(w, req)
require.Equal(t, http.StatusUpgradeRequired, w.Result().StatusCode)
req = httptest.NewRequest(http.MethodPost, "/credential-vault", strings.NewReader(`{"credentials":[],"bindings":[]}`))
req.RemoteAddr = "127.0.0.1:4321"
w = httptest.NewRecorder()
srv.handleCredentialVault(w, req)
require.Equal(t, http.StatusCreated, w.Result().StatusCode)
req = httptest.NewRequest(http.MethodDelete, "/credential-vault", nil)
req.RemoteAddr = "198.51.100.10:1234"
w = httptest.NewRecorder()
srv.handleCredentialVault(w, req)
require.Equal(t, http.StatusUpgradeRequired, w.Result().StatusCode)
req = httptest.NewRequest(http.MethodDelete, "/credential-vault", nil)
req.RemoteAddr = "127.0.0.1:4321"
w = httptest.NewRecorder()
srv.handleCredentialVault(w, req)
require.Equal(t, http.StatusNoContent, w.Result().StatusCode)
}
func TestCredentialVaultWriteAllowsForwardedProto(t *testing.T) {
t.Setenv(constants.EnvMitmproxyTransparent, "true")
t.Setenv(constants.EnvEgressMode, constants.PolicyDnsNft)
t.Setenv(constants.EnvCredentialVaultTrustedProxyCIDRs, "198.51.100.0/24")
initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`)
srv := &policyServer{
proxy: &stubProxy{updated: initial},
credentialVault: credentialvault.NewStore(nil, func() bool { return true }),
credentialVaultRequireTLS: true,
}
req := httptest.NewRequest(http.MethodPost, "/credential-vault", strings.NewReader(`{"credentials":[],"bindings":[]}`))
req.RemoteAddr = "198.51.100.10:1234"
req.Header.Set("X-Forwarded-Proto", "https")
w := httptest.NewRecorder()
srv.handleCredentialVault(w, req)
require.Equal(t, http.StatusCreated, w.Result().StatusCode)
}
func TestCredentialVaultWriteRejectsForwardedProtoFromUntrustedPeer(t *testing.T) {
t.Setenv(constants.EnvMitmproxyTransparent, "true")
t.Setenv(constants.EnvEgressMode, constants.PolicyDnsNft)
t.Setenv(constants.EnvCredentialVaultTrustedProxyCIDRs, "203.0.113.0/24")
initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`)
srv := &policyServer{
proxy: &stubProxy{updated: initial},
credentialVault: credentialvault.NewStore(nil, func() bool { return true }),
credentialVaultRequireTLS: true,
}
req := httptest.NewRequest(http.MethodPost, "/credential-vault", strings.NewReader(`{"credentials":[],"bindings":[]}`))
req.RemoteAddr = "198.51.100.10:1234"
req.Header.Set("X-Forwarded-Proto", "https")
w := httptest.NewRecorder()
srv.handleCredentialVault(w, req)
require.Equal(t, http.StatusUpgradeRequired, w.Result().StatusCode)
}
func TestCredentialVaultWriteSkipsTLSCheckByDefault(t *testing.T) {
t.Setenv(constants.EnvMitmproxyTransparent, "true")
t.Setenv(constants.EnvEgressMode, constants.PolicyDnsNft)
initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`)
srv := &policyServer{
proxy: &stubProxy{updated: initial},
credentialVault: credentialvault.NewStore(nil, func() bool { return true }),
}
req := httptest.NewRequest(http.MethodPost, "/credential-vault", strings.NewReader(`{"credentials":[],"bindings":[]}`))
req.RemoteAddr = "198.51.100.10:1234"
w := httptest.NewRecorder()
srv.handleCredentialVault(w, req)
require.Equal(t, http.StatusCreated, w.Result().StatusCode)
}
func TestCredentialVaultWriteRejectsDNSOnlyEnforcement(t *testing.T) {
t.Setenv(constants.EnvMitmproxyTransparent, "true")
t.Setenv(constants.EnvEgressMode, constants.PolicyDnsOnly)
initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`)
srv := &policyServer{
proxy: &stubProxy{updated: initial},
credentialVault: credentialvault.NewStore(nil, func() bool { return true }),
}
req := httptest.NewRequest(http.MethodPost, "/credential-vault", strings.NewReader(`{"credentials":[],"bindings":[]}`))
req.RemoteAddr = "127.0.0.1:4321"
w := httptest.NewRecorder()
srv.handleCredentialVault(w, req)
require.Equal(t, http.StatusPreconditionFailed, w.Result().StatusCode)
require.Contains(t, w.Body.String(), "dns+nft")
}
+84
View File
@@ -0,0 +1,84 @@
# Egress benchmark
**Prerequisites**: Docker, `curl` on the host. Domains: [`tests/hostname.txt`](../tests/hostname.txt) (one hostname per line; `#` and blank lines ignored). Run from `components/egress` or adjust paths.
---
## 1. `bench-dns-nft.sh`
**Compares**: plain **`curl`** container (**baseline**) → egress **`dns`** → egress **`dns+nft`**. Prints **Req/s**, **Avg**, **P50**, **P99**; percentages are vs **baseline**.
### Run
```bash
cd components/egress
./tests/bench-dns-nft.sh
```
Builds `opensandbox/egress:local` unless you set **`IMG=...`**. Optional: **`BENCH_SAMPLE_SIZE=n`** to use `n` random domains.
### View results
- **Terminal**: summary table at end.
- **Host `/tmp`**: `bench-e2e-baseline-total.txt`, `bench-e2e-dns-total.txt`, `bench-e2e-dns+nft-total.txt` (one **`time_total`** per line); `bench-e2e-{mode}-namelookup.txt`, `bench-e2e-{mode}-wall.txt`.
---
## 2. `bench-mitm-overhead.sh`
**Compares**: **`dns+nft`** without MITM vs **`dns+nft` + transparent mitmproxy**. Default **`BENCH_SCENARIOS=short,download`** — **`short`** = many HTTPS **HEAD**s; **`download`** = parallel **GET** to **`BENCH_DOWNLOAD_URL`** (default Cloudflare `__down` ~20 MiB).
### Run
```bash
cd components/egress
./tests/bench-mitm-overhead.sh
```
**`SKIP_BUILD=1`** skips image build; **`IMG`** is at the top of the script. One scenario only, e.g. **`BENCH_SCENARIOS=short`** or **`=download`**.
### View results
- **Terminal**: tables per scenario (latency / throughput vs no-MITM), plus **`E2E latency loss (avg time_total)`** in **ms/request** and **%**.
- **Host `/tmp`**:
- Latency artifacts: `bench-mitm-*-short-*.txt`, `*-download-*.tsv`, `*-wall.txt`, etc.
- **Container metrics** (always written): `bench-mitm-docker-stats-dns_nft.tsv`, `bench-mitm-docker-stats-dns_nft_mitm.tsv``unix_ts`, **`/proc/loadavg`** (load1/5/15, …), **`docker stats`** (CPUPerc, MemUsage, …). *`loadavg` inside the container often tracks the host; use for relative trends.*
---
## 3. Reference baselines (example runs)
Illustrative only — **same machine, same script**, not a SLA. **MITM** row = **`dns+nft` + transparent mitm**.
### `BENCH_SCENARIOS=download` (parallel GET, ~20 MiB, 4 streams, 1 round, 1 s sampling)
| Metric | `dns+nft` | + mitm |
|--------|-----------|--------|
| **CPUPerc** (docker) | Mostly **~25%**, max **~5.6%** | Often **~511%**, max **~10.9%** |
| **MemUsage** | **~918 MiB** | **~6891 MiB** |
| **load1** | Up to **~0.23** | Spike **~0.66**, then **~0.40.6** |
**Takeaway**: ~**2×** peak CPU% and ~**5×** RSS vs no MITM in this trace.
### `BENCH_SCENARIOS=short` (HEAD storm; **sparse** rows if the phase is short)
Run profile (sample): `10 rounds × 40 URLs × 1 inflight = 400 requests`.
| Metric | `dns+nft` | + mitm |
|--------|-----------|--------|
| **Req/s** | **3.64** | **1.90** (**-47.6%**) |
| **Avg latency (time_total)** | **0.315 s** | **0.605 s** (**+91.9%**) |
| **P50 latency** | **0.136 s** | **0.143 s** (**+5.2%**) |
| **P99 latency** | **1.439 s** | **10.006 s** (**+595.2%**) |
| **E2E latency loss (avg)** | baseline | **+289.88 ms/request (+91.95%)** |
| Metric | `dns+nft` | + mitm |
|--------|-----------|--------|
| **CPUPerc** | Hot sample **~132%** | Hot sample **~232%** |
| **MemUsage** | **~610 MiB** | **~5888 MiB** |
**`CPUPerc` > 100%** on multi-core is normal (container can use more than one core-equivalent per Dockers metric).
**Takeaway**: this sample shows clear request-side overhead from transparent MITM, about **+289.88 ms/request** on average with throughput dropping to about half. `P50` is close to baseline while `P99` grows sharply, indicating tail-latency amplification. With only **40 requests**, tail metrics are timing-sensitive; use more rounds or more domains for stable P99.
CPU/memory trend remains consistent: peak CPU sample **~1.8×** (**232/132**), and RSS is much higher with mitmdump. For denser host/container telemetry, use longer runs or **`BENCH_DOCKER_STATS_INTERVAL=0.5`**.
@@ -0,0 +1,238 @@
# Python mitmproxy Transparent Mode (with Egress)
Transparent mode starts `mitmdump --mode transparent` inside the sidecar and redirects local outbound `TCP 80/443` traffic to the mitmproxy listener via `iptables`. Its core benefits are:
- **No application changes**: no need to set `HTTP_PROXY`; app traffic is intercepted transparently.
- **Observability and extensibility**: use mitm scripts for header injection, auditing, and debugging.
- **Controlled bypass**: use `ignore_hosts` for pass-through TLS (forward only, no decryption).
Typical use case: add L7 visibility/processing at the egress boundary without changing the application networking stack.
## Quick Setup (Minimum Working Config)
### Prerequisites
- Linux network namespace with `CAP_NET_ADMIN` in the container.
- `mitmdump` installed and `mitmproxy` user present in the image (included in official egress image).
- Client/system trusts the mitm root CA; otherwise HTTPS handshakes will fail.
### Enable Transparent MITM
```bash
export OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT=true
```
By default, mitmproxy listens on `18081` and transparent redirect rules are set automatically.
### Common Optional Settings
```bash
# Optional: change listening port (default: 18081)
export OPENSANDBOX_EGRESS_MITMPROXY_PORT=18081
# Optional: load user-defined mitm addons (loaded after the system addon, comma-separated)
export OPENSANDBOX_EGRESS_MITMPROXY_SCRIPT=/path/to/your/addon.py,/path/to/another.py
```
To bypass decryption for selected domains, edit the baked-in
`components/egress/mitmproxy/config.yaml` and rebuild the image — see
"Static Configuration (config.yaml)" below.
## Configuration Reference
### Environment Variables (Per-Deployment Overrides)
| Variable | Required | Purpose | Default |
|------|----------|------|--------|
| `OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT` | Yes | Enable transparent mitmproxy (`1/true/on`, etc.) | Disabled |
| `OPENSANDBOX_EGRESS_MITMPROXY_PORT` | No | mitmdump listen port; `iptables` redirects `80/443` here | `18081` |
| `OPENSANDBOX_EGRESS_MITMPROXY_SCRIPT` | No | User mitm addon script paths (comma-separated); each is passed as `-s` and loaded after the system addon in order | Empty |
| `OPENSANDBOX_EGRESS_MITMPROXY_UPSTREAM_TRUST_DIR` | No | Trust directory for upstream TLS verification (OpenSSL style); overrides the config.yaml default | `/etc/ssl/certs` |
| `OPENSANDBOX_EGRESS_MITMPROXY_SSL_INSECURE` | No | Skip upstream TLS verification (`1/true/on`); use when clients connect by IP and SNI is unavailable | Disabled |
Notes:
- In transparent mode, mitmproxy generally recommends matching by IP/range; verify SNI/resolve behavior if using domain regex only.
- Before mitm, `iptables`, and CA export are ready, `GET /healthz` returns `503 (mitm not ready)` to prevent premature readiness.
### Static Configuration (config.yaml)
Fleet-wide, rarely-changing mitm options live in
`components/egress/mitmproxy/config.yaml`, baked into the image at
`/var/lib/mitmproxy/.mitmproxy/config.yaml` and auto-loaded by mitmdump.
This is the single source of truth for:
- `mode` (`transparent`) — mitm default is `regular`
- `listen_host` (`127.0.0.1`) — mitm default is `0.0.0.0`
- `stream_large_bodies` (`1m`) — mitm default is unset (entire body buffered)
- `ssl_verify_upstream_trusted_confdir` (`/etc/ssl/certs`) — mitm default is unset; overridable per-deployment via env
- `connection_strategy` (`lazy`) — mitmproxy 10+ changed the default from `lazy` to `eager`; pinned explicitly to preserve the historical behavior of deferring upstream connections until the full request arrives
- `ignore_hosts` (`[]`) — matches the mitm default; kept in the file as a discoverable extension point for operators adding TLS pass-through entries
Only deviations from the mitm built-in defaults are declared in `config.yaml` (the `ignore_hosts` entry is a discoverability exception; `connection_strategy` is a compatibility pin against the upstream default change). Other options that happen to match the default (`http2=true`, etc.) are omitted — the file is the diff against upstream defaults, not a full enumeration.
Precedence: command-line `--set` (from env overrides) > `config.yaml` > mitmproxy built-in defaults.
#### Overriding the built-in config.yaml
There is no env var to point mitm at an alternate config file. Operators who need different static defaults (e.g. a different `ignore_hosts` list, `connection_strategy`, or `stream_large_bodies`) should pick one of the following:
1. **Build a downstream image** that derives from the official egress image and replaces the file:
```dockerfile
FROM <opensandbox-egress-image>:<tag>
COPY my-config.yaml /var/lib/mitmproxy/.mitmproxy/config.yaml
RUN chown mitmproxy:mitmproxy /var/lib/mitmproxy/.mitmproxy/config.yaml \
&& chmod 0644 /var/lib/mitmproxy/.mitmproxy/config.yaml
```
This is the recommended path because the override is version-controlled, reviewable, and reproducible.
2. **Mount an override file at runtime** over the baked-in path. For Kubernetes, mount a `ConfigMap` as a file at `/var/lib/mitmproxy/.mitmproxy/config.yaml` (be aware that a `ConfigMap` file mount typically lands as read-only with the original UID, so verify the mitmproxy user can read it):
```yaml
volumeMounts:
- name: mitm-config
mountPath: /var/lib/mitmproxy/.mitmproxy/config.yaml
subPath: config.yaml
readOnly: true
volumes:
- name: mitm-config
configMap:
name: egress-mitm-config
defaultMode: 0644
```
Useful for staged rollouts or per-environment overrides without rebuilding the image.
3. **Single-option escape hatch via env-driven `--set`** (already supported for the documented env variables above). This only works for options exposed via env and only for the single specific override; it cannot replace the whole file.
Do not edit `config.yaml` inside a running container — the file lives in the container layer, edits are lost on restart, and the mitmproxy user has read-only access by design.
## Common Configuration Templates
### 1) Enable Transparent MITM Only
```bash
export OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT=true
```
### 2) System Addon (Always On)
The bundled system addon at `/var/egress/mitmscripts/system.py` is shipped in the egress image and loaded automatically whenever transparent mode is enabled. It stays wire-transparent (no headers added or altered) and currently provides:
- Forces streaming (`flow.response.stream = True`) for SSE (`text/event-stream`) and chunked responses, so each chunk is forwarded immediately instead of being buffered up to the `stream_large_bodies=1m` threshold (critical for LLM streaming UX).
- Redacts credential values from response headers. Response bodies are not rewritten by default.
The system addon is always loaded and cannot be disabled via configuration. To override its behavior, supply user addons via `OPENSANDBOX_EGRESS_MITMPROXY_SCRIPT` (comma-separated for multiple scripts); user addons are loaded after the system addon in the order given and may observe or override its hooks.
### 3) Add User Addons Alongside the System Addon
```bash
export OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT=true
# Single addon
export OPENSANDBOX_EGRESS_MITMPROXY_SCRIPT=/path/to/your/addon.py
# Multiple addons (comma-separated)
export OPENSANDBOX_EGRESS_MITMPROXY_SCRIPT=/path/to/auth.py,/path/to/logging.py
```
User addons are loaded after the system addon (`-s system.py -s auth.py -s logging.py`), in the order given. Later addons observe and may override hooks from earlier ones.
### 4) Bypass Decryption for Specific Domains (e.g. log upload)
Edit `components/egress/mitmproxy/config.yaml` and append to `ignore_hosts`,
then rebuild the egress image:
```yaml
ignore_hosts:
- '.*\.log\.aliyuncs\.com'
```
`ignore_hosts` means **no decryption**, not "completely bypass mitm process":
mitm still proxies the TCP connection, it just forwards bytes without
breaking TLS, and addons do not see request/response content.
### 5) Use a Fixed CA (consistent fingerprint across replicas)
If CA files already exist in `confdir`, mitmproxy reuses them instead of regenerating on each startup. Typical paths:
- `/var/lib/mitmproxy/.mitmproxy/mitmproxy-ca.pem` (private key)
- `/var/lib/mitmproxy/.mitmproxy/mitmproxy-ca-cert.pem` (public cert)
Ensure correct permissions (for example `mitmproxy:mitmproxy`, private key mode `600`).
## Relationship with Policy/DNS
Transparent mitmproxy does not automatically consume egress `NetworkPolicy`. Domain allow/deny behavior is still determined by DNS + (optional) nft rules. If L7 policy enforcement is needed, implement it in mitm scripts.
## Implementation Notes and Limits
Startup flow (high level):
1. Start mitmdump as user `mitmproxy`, listening on `127.0.0.1:<port>`.
2. Wait until the local listener is reachable.
3. Apply IPv4 `iptables` redirect rules: except loopback and mitmproxy-owned traffic, redirect outbound `80/443` to mitm port.
Limits:
- Currently IPv4 `iptables` only; IPv6 is not automatically handled.
- Non-Linux environments (for example local macOS runtime) are not supported for transparent mode.
- Full HTTPS decryption introduces CPU/memory and certificate trust overhead; benchmark before production rollout.
## Process Supervisor (Crash Recovery)
The egress sidecar includes a built-in supervisor that monitors the `mitmdump` child process and automatically restarts it on unexpected exits.
### Restart behavior
When `mitmdump` exits unexpectedly, the supervisor restarts it with **exponential backoff**: 1 s, 2 s, 4 s, ..., capped at **30 s**. Retries continue indefinitely until the process starts successfully or the egress sidecar itself shuts down.
A successful restart requires two conditions:
1. `mitmdump` process starts without error.
2. The listen port (`127.0.0.1:<port>`) accepts TCP connections within 15 seconds.
If the listener does not come up in time, the half-started process is gracefully terminated (SIGTERM → wait → SIGKILL) before the next attempt, so the port is released cleanly.
### Generation tagging
Each `mitmdump` launch is assigned a monotonically increasing **generation number**. When a process exits, the exit event carries the generation it was launched with. The supervisor compares this against the currently-live generation:
- **Match**: the live process just died — trigger restart.
- **Mismatch**: a stale process from a previous failed attempt was reaped — ignore.
This prevents restart storms where multiple rapid failures queue up cascading restart attempts.
### Health gate integration
When transparent mitmproxy is enabled:
- `/healthz` returns **503** until the full mitm stack is ready (process started, listener up, iptables installed, CA exported).
- On crash, the health gate is set back to not-ready (503) immediately.
- After a successful restart and listener readiness, the health gate is restored.
Kubernetes readiness probes that hit `/healthz` will stop routing traffic to the sandbox during the restart window.
### Graceful shutdown
When the egress sidecar receives `SIGTERM` or `SIGINT`:
1. The supervisor watcher goroutine exits (context cancelled).
2. `iptables` transparent redirect rules are removed.
3. `mitmdump` receives `SIGTERM`; if it does not exit within 5 seconds, `SIGKILL` is sent.
Any `OnExit` callbacks still blocked on the restart channel are unblocked via a dedicated shutdown channel, preventing goroutine leaks.
### Observability
All supervisor activity is logged with the `[mitmproxy]` prefix:
| Log pattern | Meaning |
|-------------|---------|
| `mitmdump exited (gen=N): <error>; restarting...` | Live process crashed; restart initiated |
| `ignoring stale exit event (gen=N, current=M)` | Old generation reaped; no action needed |
| `restart attempt N failed; retrying in Xs` | Launch or listener wait failed; backing off |
| `mitmdump restarted (pid P, gen N, attempt M)` | Successful restart |
| `dropping exit event during shutdown` | Exit event discarded because egress is shutting down |
+76
View File
@@ -0,0 +1,76 @@
# OpenTelemetry Metrics (Current Egress Support)
This page lists the OpenTelemetry metrics currently implemented in egress.
## Meter
- `opensandbox/egress`
## Metrics
| Metric | Type | Unit | Meaning |
|---|---|---|---|
| `egress.dns.query.duration` | Histogram | `s` | Upstream DNS forward latency (recorded for allowed queries). |
| `egress.policy.denied_total` | Counter | - | Number of DNS queries denied by policy. |
| `egress.nftables.rules.count` | Observable Gauge | `{element}` | Approximate policy size after last successful static apply. |
| `egress.nftables.updates.count` | Counter | - | Number of successful nftables updates (static apply + dynamic IP add). |
| `egress.system.memory.usage_bytes` | Observable Gauge | `By` | System memory used bytes (Linux: gopsutil; non-Linux build: `0`). |
| `egress.system.cpu.utilization` | Observable Gauge | `1` | CPU busy ratio in `[0,1]` (Linux: gopsutil; non-Linux build: `0`). |
## Shared Attributes
All egress metrics may include shared attributes:
- `sandbox_id` from `OPENSANDBOX_EGRESS_SANDBOX_ID` (when set)
- extra key/value attributes from `OPENSANDBOX_EGRESS_METRICS_EXTRA_ATTRS` (when set)
## OTEL Endpoint Configuration
Metric export is enabled only when at least one OTLP endpoint is set.
- `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` (preferred)
- `OTEL_EXPORTER_OTLP_ENDPOINT` (fallback)
If both are unset, egress keeps metrics local (no OTLP export).
### Minimal Example
```bash
export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT="http://otel-collector:4318"
```
### Service Name
`service.name` is set by egress code as `opensandbox-egress-<version>`.
## Structured logs (JSON)
Egress structured logs are emitted by zap (typically to stdout). OTLP log export is not implemented in-tree.
### Common fields
- `sandbox_id` is included when `OPENSANDBOX_EGRESS_SANDBOX_ID` is set.
- key/value pairs from `OPENSANDBOX_EGRESS_METRICS_EXTRA_ATTRS` are merged into the root logger.
- `opensandbox.event` identifies the event family.
### Outbound DNS logs
- `opensandbox.event=egress.outbound`
- emitted on allow-path DNS handling (success or forward error)
- common payload keys:
- `target.host` (normalized query name)
- `target.ips` (resolved A/AAAA addresses, when present)
- `peer` (IP-only destination path)
- `error` (forward failure message)
### Policy lifecycle logs
- `opensandbox.event=egress.loaded` (initial effective policy loaded)
- `opensandbox.event=egress.updated` (policy update applied)
- `opensandbox.event=egress.update_failed` (policy update failed)
Common policy fields:
- `egress.default` (`allow` / `deny`)
- `rules` (rule summary; for `egress.updated`, reflects current request body semantics)
- `error` (present for `egress.update_failed`)

Some files were not shown because too many files have changed in this diff Show More