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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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));
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(", ")}`);
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
# OpenSandbox Claude Guide
|
||||
|
||||
See `AGENTS.md` for all rules, routing, and conventions.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
[](https://www.star-history.com/#opensandbox-group/OpenSandbox&type=date&legend=top-left)
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`opensandbox-group/OpenSandbox`
|
||||
- 原始仓库:https://github.com/opensandbox-group/OpenSandbox
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 363 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 249 KiB |
@@ -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 }
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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" },
|
||||
]
|
||||
@@ -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/**
|
||||
@@ -0,0 +1,4 @@
|
||||
# OpenSandbox Egress
|
||||
|
||||
Documentation: [docs/components/egress.md](../../docs/components/egress.md)
|
||||
|
||||
@@ -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 60–360s (was 60–300s) 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
|
||||
@@ -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 \
|
||||
.
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 **~2–5%**, max **~5.6%** | Often **~5–11%**, max **~10.9%** |
|
||||
| **MemUsage** | **~9–18 MiB** | **~68–91 MiB** |
|
||||
| **load1** | Up to **~0.23** | Spike **~0.66**, then **~0.4–0.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** | **~6–10 MiB** | **~58–88 MiB** |
|
||||
|
||||
**`CPUPerc` > 100%** on multi-core is normal (container can use more than one core-equivalent per Docker’s 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 |
|
||||
@@ -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`)
|
||||